Webhooks
Instead of polling, Manage²Hero reports an event on its own: you register an https address of your system and receive a signed POST for every event you subscribed to. The message is thin: it only states what happened and for which object. You fetch the details with your token via GET (e.g. GET /v1/payments/by-ids?ids=…). The object details are thus protected by the read permissions of your token. The message itself, however, names the event type, timestamp and object identifier, even if the token lacks the matching read scope (a token with only webhooks:manage is enough). Therefore give webhook tokens the matching read scopes and register trusted targets only. The most important rules for operation are summarized in the integration checklist.
enqueue_failures on the webhook shows how many events are currently being queued again or are lost. While a webhook is disabled, no messages are queued for it. Therefore reconcile regularly through the change sync (updated_since, /changes); details under Known limits and After a shut-off.Endpoints
All endpoints need the scope webhooks:manage. The creator of the token needs the permission “API access” with full access for that. Unknown or foreign identifiers always give 404 not_found.
| Request | Purpose |
|---|---|
GET /v1/webhooks | All webhooks of the organization (at most 10), never a secret; not paginated: data.items without next_cursor |
POST /v1/webhooks | Create; Idempotency-Key required; the response contains the secret once |
GET /v1/webhooks/{id} | One webhook |
PATCH /v1/webhooks/{id} | Change url, events, description (null deletes) and active |
DELETE /v1/webhooks/{id} | Delete, including its delivery history |
POST /v1/webhooks/{id}/rotate-secret | Renew the secret; Idempotency-Key required; new secret shown once |
POST /v1/webhooks/{id}/test | Queue the test event webhook.test; Idempotency-Key required |
GET /v1/webhooks/{id}/deliveries | Delivery history, newest first, with cursor and time filter |
POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry | Send one failed or abandoned delivery again; Idempotency-Key required |
POST /v1/webhooks/{id}/deliveries/retry-failed | Send up to 100 failed or abandoned deliveries again; Idempotency-Key required |
POST /v1/sandbox/events | Sandbox token only: simulate an event for a sample object of the sandbox; Idempotency-Key required |
livemode (body) and X-M2H-Livemode (header): false for events of a sandbox, true for all others. Check it before you book or forward anything. Deliveries queued before the field was introduced do not carry it (treat a missing field as true): a delivery keeps exactly the same body on every retry and replay. More on the Sandbox page.Create a webhook
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) appears only in this response and in the response to rotate-secret. Manage²Hero stores it encrypted and only for signing; it cannot be read afterwards. If you lost it, create a new one with rotate-secret.| Rule | Details |
|---|---|
| Address | https on port 443 only (no port or an explicit :443; any other port is rejected), publicly reachable addresses only (no private, loopback or link-local addresses, no internal names), no credentials (user:pass@), no # fragment, at most 2048 characters. The name is checked via DNS when you create the webhook and again on every delivery (protection against redirects to internal targets, SSRF). Violation: 400 webhook_url_invalid. |
| Events | events with at least one entry, no duplicates. The possible events are listed below. |
| Number | At most 10 webhooks per organization, disabled ones count: 409 webhook_limit_reached (error.details: limit and used, e.g. { "limit": 10, "used": 10 }; in a sandbox only limit). |
| Retry | Same Idempotency-Key (with the same token) and same content: if the first creation had completed, the retry answers 200 with the same webhook, but without secret. If the first request was interrupted before completing, the retry creates the webhook now: 201 with a new secret. Different content: 409 idempotency_conflict. |
| Server delivery | Messages are only sent from production. In development and staging, management and delivery history work, but deliveries stay pending. |
Events
| type | When | data | Fetch details with |
|---|---|---|---|
payment.received | An incoming payment (amount greater than 0) is recorded | payment_id | GET /v1/payments/by-ids?ids={id} |
dunning.sent | A dunning notice was sent | dunning_notice_id | GET /v1/dunning/notices/{id} |
contract.signed | A contract reaches the status “signed” | lease_id | GET /v1/contracts/{id} |
invoice.created | An invoice is created (not a draft) or released from draft | invoice_id | GET /v1/invoices/{id} |
document.created | A document (main folder of the tenant file, location file) is stored | document_id | GET /v1/documents/{id} |
email.sent | A system email to a tenant is sent (same templates as GET /v1/emails) | email_id | GET /v1/emails/{id} |
damage.created | A damage case file is created, also via POST /v1/damages | damage_id | GET /v1/damages/{id} |
contract.terminated | A termination is confirmed (by the landlord or a confirmed tenant request). The confirmation is reported, not the end of the contract: the date is end_date of the termination in the details | lease_id | GET /v1/contracts/{id} |
invoice.cancelled | An invoice (not a draft) is cancelled. data is the cancelled original invoice; the cancellation invoice reports invoice.created | invoice_id | GET /v1/invoices/{id} |
damage.updated | A damage case file changes a field visible in the API (status, type, priority, description, date, causer type, reporter type, unit/location, case number). Documents and reports do not trigger it | damage_id | GET /v1/damages/{id} |
webhook.test | Test event; cannot be subscribed to | webhook_id | — |
In the column “Fetch details with”, {id} stands for the identifier from data. For the details your token needs the matching read scope (payments:read, dunning:read, contracts:read, invoices:read, documents:read, emails:read, damages:read). For each webhook and object there is at most one message per event type: an invoice draft that switches back and forth reports only once. Exception: damage.updated is sent again for every change, and contract.terminated again when a contract is terminated once more after a revocation; several messages for the same object are normal there. The message is a hint to the current state, not a log of every intermediate state. The order of several messages is not guaranteed; check the current state via GET when it matters. The three types contract.terminated, invoice.cancelled and damage.updated go only to webhooks that list them explicitly in events; existing webhooks do not change. Your receiver should ignore unknown event types.
The event is created in the same database transaction as the business event: if that is rolled back, there is no message either. The server then sends once a minute, never during the action in the management app.
The message
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 in the section webhooks (OpenAPI 3.1), not under paths: one entry per event type (payment.received, dunning.sent, contract.signed, invoice.created, document.created, email.sent, damage.created, contract.terminated, invoice.cancelled, damage.updated and webhook.test), the body under webhooks.<event type>.post.requestBody. The schemas are embedded there and have no names of their own under components.schemas. Note for code generators: many generators only evaluate paths and create no types for the messages; use a generator that supports the section webhooks (OpenAPI 3.1) or describe the message according to this handbook.The event identifier id in the body (for information also in the header X-M2H-Event-Id) stays the same on every retry — store processed identifiers and ignore duplicates. The message contains no organization, no names and no amounts. Answer with 2xx only after you have stored the message durably, ideally in an inbox table with a unique event identifier (a duplicate is then simply ignored). Process it afterwards in a separate step that uses the identifier as the idempotency key for everything it books or sends; if storing fails, answer with 5xx so that Manage²Hero tries again. The content of your answer does not matter and is never stored. Anything else counts as a failure: another status, a timeout (10 seconds), a certificate error, and also a redirect (3xx) — it is not followed.
Verify the signature
The header is X-M2H-Signature: t=<Unix seconds>,v1=<hex>. Here t is the time of this send attempt, not of the event (created_at); every retry has a different t. The value v1 is an HMAC-SHA256 over the text "<t>.<raw body>". The key is the secret, the whole string whsec_… as UTF-8; the output is hexadecimal.
- Use the raw body (the bytes as they arrived), never a re-serialized one.
- Read
tand allv1values from the header. During a rotation there are twov1values. - Compute the HMAC and compare it to each
v1in constant time. - Reject the message if
tdiffers from your clock by more than 5 minutes (protection against replayed messages). On every retry Manage²Hero setstanew and signs anew. - Only after a successful signature check, compare the
idfrom the body against identifiers you already processed. The headerX-M2H-Event-Idis not signed; never use it as a duplicate or idempotency key.
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; }Test vector
To check your implementation: secret whsec_testvektor_1, t = 1700000000, body {"id":"e1","type":"payment.received"} gives v1 = 0bb862164c5c29039c8121a86d1c9ea83b9ed274f6bb9686383047a29c4014e7. For this vector your check must set the current time to t (the examples take it as the fourth parameter), otherwise the time check rejects the old timestamp.
More cases are in the file test/signatur-testvektoren.json of the starter project: raw bytes (UTF-8, a trailing newline, re-serialized JSON), rotation with two v1, the time window (exactly 300 and 301 seconds), tampering and duplicates. Every case gives the secret, the header, the body as hex bytes, the receiver's clock and the expected result; check your implementation in any language against the same file.
Retries and automatic shut-off
A message is attempted up to 6 times: immediately, then after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (a little over 8.5 hours in total). If the sixth attempt fails as well, the webhook is disabled (active = false, disabled_reason = delivery_failed). Nothing is discarded: open messages of this webhook are paused (paused), and as long as the API of your organization is enabled, new events keep being recorded (also paused): up to 10,000 per webhook and for 29 days at most. After re-enabling, the server resumes them by itself (see below). Exceptions: the message with the sixth failed attempt stays failed (you can deliberately queue it again, so that a message your system always rejects does not end in an endless loop), and test events (webhook.test) are discarded. All users with full access to API access receive a notification in the management app, with the host, never the whole address; after 21 days the administration is reminded if paused messages are about to expire. A message that is being sent at the moment of the shut-off runs to its end: if it arrives, it counts as delivered, otherwise it is paused.
If messages arrive unusually late, check the status page first: it shows whether webhook delivery of the platform is currently delayed or interrupted. Paused webhooks and disabled endpoints of your organization do not appear there as a disruption – you only see them on the webhook itself (active, disabled_reason, pending_deliveries).
| disabled_reason | Meaning |
|---|---|
manual | Disabled by PATCH with active false |
delivery_failed | 6 attempts failed |
token_revoked | The token the webhook was created (or last activated) with was revoked or deleted (immediately) |
token_expired | That token has expired |
creator_lost_access | The creator of the token is no longer active or lost the permission “API access” with full access |
api_disabled | The API is switched off for the organization |
PATCH /v1/webhooks/{id} with { "active": true }. The calling token then becomes the creator token, even for a webhook that is still active, and no open messages are lost.Running long-lived integrations
- Create the webhook with a token of a dedicated user account that serves only this integration and is not tied to a person. If the creator of the token is deactivated or loses the permission “API access” at full level, the webhook is disabled (
creator_lost_access). - You choose the validity of a token on creation from 30, 90, 180 or 365 days. Therefore take the webhook over with a new token before it expires (see above). A reminder email goes out 14 days before expiry to the administrators of API access and to the creator; also set a reminder of your own.
- Monitor the webhooks: call
GET /v1/webhooksregularly (e.g. daily) and raise an alarm whenactiveisfalse.disabled_reasonnames the cause. - Note: with the token whose expiry disables the webhook you will not get a response with
active = falseafter it expires, but 401 (token_expired;invalid_tokenfor a revoked token). Therefore also treat error responses (anything but 2xx, especially 401) as an alarm, or monitor with a token that is renewed independently.
After a shut-off
Re-enable: first remove the cause (is the receiver reachable? is the token valid?). Then PATCH /v1/webhooks/{id} with { "active": true }; the calling token becomes the creator token.
Resumption: after { "active": true } the server resumes the paused messages by itself, oldest first, at most 60 per minute per webhook (10,000 messages therefore take a little under three hours), with the same event identifier id and a new attempt counter. Until then, paused_deliveries on the webhook shows the remainder. Messages older than 29 days are no longer delivered (abandoned, error = paused_expired). If you do not want the paused messages, for example because you catch up through the change sync, enable with { "active": true, "paused_deliveries": "discard" }: they are then discarded (abandoned).
Catch up on what was missed: after re-enabling, queue failed and abandoned messages again with the replay endpoints (at most 29 days old, at most 1000 per webhook and hour, active webhooks only, no test events). What could no longer be recorded during the shut-off (more than 10,000 events) is shown by recording_gap on the webhook (from, to; to is null while the gap is open; a later overflow widens it, its start never moves later); catch up on it through the change sync (updated_since = from). After an outage of your system, usually one message stays failed (the one with the sixth failed attempt): PATCH active: true, then retry-failed. Each resource has its own cursor: a cursor only works for the resource it came from, and any other …/changes rejects it with 400 invalid_cursor. Therefore proceed separately for each resource (starting point …/changes/cursor, full fetch, …/changes until next_cursor is null) and store a separate resume_cursor per resource:
- If you still have a stored
resume_cursor, call…/changesfrom that cursor. A page has at mostlimitentries (default 100, at most 500): keep paging withnext_cursoruntil it isnull. Only then is the sync complete and the response delivers the newresume_cursor(on the pages before it isnull). Reload the returned identifiers with…/by-ids(at most 100 per call) and process them durably. Store the newresume_cursoronly afterwards: on an error keep the previous cursor and repeat the sync, because changes already delivered do not appear again with the new cursor. Changes are kept for 90 days; an older cursor returns 410cursor_expired. - Otherwise get a new starting point with
…/changes/cursorfirst, then fetch the list in full. Per request (fromup to and includingto) at most 366 days are allowed for bookings, 366 for incoming payments, 1,831 for invoices and 1,831 for dunning notices, otherwise the API answers 400range_too_large. Therefore fetch in consecutive time windows, all pages per window; the next window starts on the day after thetoof the previous one. Fetch documents and emails withperiod=all. Then use…/changesfrom the starting cursor to catch up on whatever changed in the meantime (again page withnext_cursoruntilnull, reload and process the identifiers, and store the deliveredresume_cursoronly afterwards). - The change sync exists for bookings, invoices, incoming payments, dunning notices, documents, emails, contracts and damages. Contracts and damages follow the same procedure (starting cursor
/v1/contracts/changes/cursoror/v1/damages/changes/cursor, full fetch withperiod=all,…/changes,…/by-ids). The stream also reports deletions and visibility changes: deleted or marked-for-deletion contracts, reset countersignatures, deleted terminations and amendments, deleted damages, damage documents and reports./v1/damages/by-idsreturns the list shape withoutdocuments; get the document IDs withGET /v1/damages/{id}. The former query since a point in time (fromorupdated_since) is still possible but no longer recommended: it does not see deletions. If a call keeps returning 410cursor_expired, the stream was invalidated after an internal error: start over (full fetch), after a short wait if necessary.
Rotate the secret
POST /v1/webhooks/{id}/rotate-secret (with Idempotency-Key) returns a new secret, shown once. The previous one stays valid for 24 hours as a second signature (previous_secret_valid_until): during that time every message carries two v1 entries, the new one first. Check against both secrets, or against the new one as soon as it is stored. That way there is no outage. After that only the new one applies. If you rotate a second time within the 24 hours, the grace period of the first secret ends immediately; there is only ever one “old” secret.
A retry with an earlier Idempotency-Key does not rotate again within 30 days. After that the same key counts as a new operation, because the record is deleted after 30 days.
Secret or response lost
The secret is shown only once. If the response to the creation got lost, you do not know whether the webhook was created, and you do not know its id. First repeat the same creation with the same Idempotency-Key (with the same token) and the same content. If the first creation had completed, you get 200 with the webhook including its id, but without the secret. If it was interrupted before, the webhook is created now: 201 with a secret (the creation can then also fail on the maximum number or the address check). Alternatively find the id with GET /v1/webhooks.
If you lack the secret of an existing webhook, for example because the response to rotate-secret got lost, call rotate-secret with a new Idempotency-Key. The same rotate-secret request with the same key does not output the secret again (within 30 days); it only returns the webhook without secret. The new call creates another secret. The secret that was valid before stays valid for 24 more hours as a second signature and not after that; a secret that was already in its grace period ends immediately. Therefore store the new secret at your receiver right away.
Testing
POST /v1/webhooks/{id}/test (with Idempotency-Key) queues the event webhook.test (data: { "webhook_id": "…" }), with the same signature and headers. There is one attempt, and a failure does not disable the webhook. The server sends at most one minute later. The result is in the delivery history (status, response_status, error). At most 3 test events wait at the same time per webhook, otherwise 429 rate_limited. The same key returns the same test delivery as long as it is in the delivery history (30 days).
Delivery history
GET /v1/webhooks/{id}/deliveries shows what was sent to your address and how it answered. Parameters: the time filter (period, date, from/to; without any, the last 30 days), status (pending, succeeded, failed, abandoned, paused), event_type, limit (1–200, default 50) and cursor. The history is kept for 30 days.
Fields: id, event_id, event_type, status, attempts, replay_count, created_at, last_attempt_at, next_attempt_at (pending only), delivered_at, response_status (HTTP status of your answer) and error. The error is a fixed code: timeout, connection_failed, tls_error, blocked_target (the address now points to a non-public target or uses a port other than 443), redirect_not_followed or http_status. The response body of your system and the payload are never included.
Send events again
Messages that failed for good (failed) or were discarded when the webhook was disabled (abandoned) can be queued again without resorting to change sync. The message carries the same event identifier id and the same body as before; timestamp t and signature are new. As always, the server sends on its one-minute cycle.
Typical procedure after an outage of your system:
- Fix the cause and re-enable the webhook:
PATCH /v1/webhooks/{id}with{ "active": true }. - Queue the missed messages again with
POST /v1/webhooks/{id}/deliveries/retry-failed. If the response returnsremaininggreater than 0, repeat the call with a newIdempotency-Keyuntilremainingis 0. - In the delivery history you see the messages as
pendingagain;replay_countcounts how often a delivery was restored.
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…"
}You queue a single delivery (identifier from the delivery history) again like this:
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)"| Rule | Details |
|---|---|
| Which messages | failed and abandoned that are at most 29 days old (the history is kept for 30 days; one day of reserve leaves time for the retry sequence). No test events: POST /v1/webhooks/{id}/test triggers a new test event. Waiting (pending), delivered (succeeded) and too old messages: 409 delivery_not_replayable, error.details names status and reason (not_failed, test_event or expired). |
| Bulk call | The body is optional (empty or {} = all) and at most 4 KB (otherwise 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 and limit (1–100). The oldest messages come first. Response: replayed (count) and remaining (further matches). |
| Precondition | The webhook is active and able to send (valid token). Otherwise 400 validation_error with a hint to PATCH with { "active": true }. |
| Attempts | Attempts start over at 0 (attempts; X-M2H-Delivery-Attempt starts at 1 again). If these 6 attempts fail as well, the webhook is disabled as usual. |
| Limits | At most 100 messages per bulk call and 1000 per webhook and hour: otherwise 429 rate_limited with Retry-After (error.details: limit, retry_after). If nothing matches any more, the call answers with replayed 0. |
| Retry | Idempotency-Key is required. The same key for the same delivery or the same filters returns the result of the first call and queues nothing again (replayed false for a single delivery). The record is kept for 30 days. |
id as before.paused) by itself; a replay is not possible for them (409 delivery_not_replayable, reason paused). Events beyond the limit of 10,000 per webhook and shut-off are not recorded (recording_gap); close that gap through the lists and change sync (/changes). Messages that were being sent at the moment of the shut-off appear as paused only after up to 3 minutes.Starter project, Postman and test vectors
Starter project (Node.js)
A runnable receiver project without third-party dependencies (Node.js 22.13 or newer): it verifies the signature, stores the message durably, only then answers with 2xx, processes separately and reconciles regularly. It comes with npm run simulate (try it without Manage²Hero), tests, the shared test vectors and a collection of the calls in .http format. Download the starter project (ZIP, no sign-in, manage2hero-webhook-starterprojekt.zip). The example code is under the MIT license; the API itself is subject to the terms of use.
The starter project's reconciliation covers all seven subscribable resources. M2H_RECONCILE decides which ones it reconciles (default payments, because every resource needs the read scope of its objects):
| Resource | Procedure | Full fetch |
|---|---|---|
payments | change stream | from/to, ≤ 366 days |
invoices | change stream | from/to, ≤ 1,831 days |
dunning_notices | change stream | from/to, ≤ 1,831 days |
documents | change stream | period=all |
emails | change stream | period=all |
contracts | change stream | period=all |
damages | change stream | period=all |
Contracts and damages use the same change stream as the other resources (see After a shut-off); the stream itself reports deletions and visibility changes. The mirror holds the list shape (damages without documents; the document IDs come from GET /v1/damages/{id}). An occasional full fetch with --full is still useful, e.g. for changed master data.
Postman collection
The same calls as an import-ready collection (Collection v2.1): /openapi.json?format=postman&lang=en. It holds the variables base_url and token, webhook management with test and replay, the reconciliation per resource, paging, error cases and fixed Idempotency-Keys (one key per request until you set idempotency_renew to true). In Postman: Import → Link with the full address, or save the file and import it. Put your token into the variable token only, never into the exported file.
Known limits
id is created. Keep your processing idempotent in business terms: reconcile by the event identifier and check the state of the object via GET before you act.Not gap-free. An event that cannot be queued for technical reasons is recorded and queued again automatically; it keeps its event identifier and timestamp but arrives later. The check whether any webhook subscribes to the event at all deliberately runs before this protection (it would otherwise noticeably slow down every business event): if that check itself fails, the business operation is aborted, so neither a business event nor a message is created. If the server has to determine the recipients again when queueing again, this is an approximation: webhooks created only after the event do not receive it, subscriptions added later do. After eight failed attempts or after 29 days an event counts as lost; the administration is notified and enqueue_failures keeps counting it. If queueing and recording fail at the same time, only a warning remains in the database log. Therefore reconcile regularly through the list endpoints (updated_since, change sync /changes). Further limits: the order of messages is not guaranteed, a message can arrive more than once (retries), and recording during a shut-off has limits: at most 10,000 events per webhook and 29 days (after that recording_gap; older paused messages are no longer delivered). After an outage one message stays failed (queue it again: see above).
Integration checklist
The essentials at a glance. The details are in the sections of this handbook and in the rules on idempotency.
- Token permissions: The token needs
webhooks:manageand, for every subscribed event, the matching read scope (e.g.payments:readforpayment.received); otherwise loading the details fails with403. Grant only as many permissions as the program needs. - Verify the signature on the raw body: Verify
X-M2H-Signatureover the bytes as they arrived (not over re-serialized JSON), with a constant-time comparison and a timestamp tolerance of 5 minutes. Reject invalid messages (e.g. with400) and do not store them. - Inbox pattern: Store the message together with its event identifier
id(unique) durably and answer with2xxonly afterwards; if storing fails, answer with5xx. Process it in a separate step and ignore duplicates by the identifier. The order of messages is not guaranteed. - Durable idempotency: For writing calls, store the
Idempotency-Keydurably before sending and reuse it with the same body on every retry — also after a restart. The event identifier serves as the key for everything the processing books or sends. - Full reconciliation and change cursor: Webhooks are a hint, not a gapless source. Per resource proceed in this order: (1) get the entry point
…/changes/cursorand save it before the full fetch, (2) fetch the list completely, (3) work through…/changesfrom the entry cursor withnext_cursoruntilnull, and (4) save the returnedresume_cursoronly after processing; afterwards regularly from the savedresume_cursor. Each resource has its own cursor. A cursor is valid for 90 days; afterwards (410 cursor_expired) start again at step 1. Add an occasional full fetch, e.g. weekly. - 429 and backoff: On
429, wait forRetry-Afterfirst; on timeout,500and503retry with a growing wait and an upper limit of attempts. One exception among the 4xx:409 readings_locked(meter readings are being edited right now) is retried after a few seconds with the same key. A retry does not fix other errors; the action pererror.codeis in the error catalog. - Replay: A message is tried up to 6 times. Failed (
failed) and abandoned (abandoned) deliveries can be queued again after fixing the cause withPOST /v1/webhooks/{id}/deliveries/{delivery_id}/retryor in bulk with…/deliveries/retry-failed— at most 29 days old, at most 1000 per webhook and hour, only for an active webhook. - Monitoring: Check
GET /v1/webhooksregularly and act on these values:active: false(disabled, reason indisabled_reason),enqueue_failuresabove 0 (events that could not be queued),recording_gapnotnull(gap in the recording: catch up through the change sync fromfrom),pending_deliveriesstaying high (backlog) andpaused_deliveriesabove 0 (paused deliveries wait for resumption). The status page shows the operational state of the platform. - Time limits at a glance: Replay window 29 days · change cursor 90 days · delivery history 30 days. If you are down for longer than the replay window, catch up through the change sync.
- Code generators: The webhook messages are in the
openapi.jsonin the sectionwebhooks(one entry per event type), not underpaths. Generators that only evaluatepathscreate no types for them.
Security in brief
- https on port 443 only, public targets only, checked on creation and on every delivery (also against DNS changes); no redirects.
- Signed messages with a timestamp (tolerance 5 minutes) and a constant event identifier.
- The secret is stored encrypted only and shown once; if you lose it, rotate.
- Thin payload: object details are only available through the read endpoints with the permissions of your token; type, timestamp and identifier are in the message.
Next: Permissions, reference of the webhook endpoints limits and the threat model.