Skip to content

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.

Webhooks are a hint, not a gap-free source. The event is queued in the same database transaction as the business event: if that is rolled back, there is no message either. If queueing fails technically (for example because of a lock conflict in the database), the business event is completed anyway; the event is recorded in an error log and queued again automatically by the server (every minute, at most eight attempts over about 45 hours). Only if recording fails as well is it lost. The field 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.

RequestPurpose
GET /v1/webhooksAll webhooks of the organization (at most 10), never a secret; not paginated: data.items without next_cursor
POST /v1/webhooksCreate; 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-secretRenew the secret; Idempotency-Key required; new secret shown once
POST /v1/webhooks/{id}/testQueue the test event webhook.test; Idempotency-Key required
GET /v1/webhooks/{id}/deliveriesDelivery history, newest first, with cursor and time filter
POST /v1/webhooks/{id}/deliveries/{delivery_id}/retrySend one failed or abandoned delivery again; Idempotency-Key required
POST /v1/webhooks/{id}/deliveries/retry-failedSend up to 100 failed or abandoned deliveries again; Idempotency-Key required
POST /v1/sandbox/eventsSandbox token only: simulate an event for a sample object of the sandbox; Idempotency-Key required
Sandbox and livemode. Every message carries 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
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"}'
Response 201
{
  "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…"
}
The secret (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.
RuleDetails
Addresshttps 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.
Eventsevents with at least one entry, no duplicates. The possible events are listed below.
NumberAt 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).
RetrySame 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 deliveryMessages are only sent from production. In development and staging, management and delivery history work, but deliveries stay pending.

Events

typeWhendataFetch details with
payment.receivedAn incoming payment (amount greater than 0) is recordedpayment_idGET /v1/payments/by-ids?ids={id}
dunning.sentA dunning notice was sentdunning_notice_idGET /v1/dunning/notices/{id}
contract.signedA contract reaches the status “signed”lease_idGET /v1/contracts/{id}
invoice.createdAn invoice is created (not a draft) or released from draftinvoice_idGET /v1/invoices/{id}
document.createdA document (main folder of the tenant file, location file) is storeddocument_idGET /v1/documents/{id}
email.sentA system email to a tenant is sent (same templates as GET /v1/emails)email_idGET /v1/emails/{id}
damage.createdA damage case file is created, also via POST /v1/damagesdamage_idGET /v1/damages/{id}
contract.terminatedA 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 detailslease_idGET /v1/contracts/{id}
invoice.cancelledAn invoice (not a draft) is cancelled. data is the cancelled original invoice; the cancellation invoice reports invoice.createdinvoice_idGET /v1/invoices/{id}
damage.updatedA 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 itdamage_idGET /v1/damages/{id}
webhook.testTest event; cannot be subscribed towebhook_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

HTTP
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"}}
Formal description in the OpenAPI. The messages are in the 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.

  1. Use the raw body (the bytes as they arrived), never a re-serialized one.
  2. Read t and all v1 values from the header. During a rotation there are two v1 values.
  3. Compute the HMAC and compare it to each v1 in constant time.
  4. Reject the message if t differs from your clock by more than 5 minutes (protection against replayed messages). On every retry Manage²Hero sets t anew and signs anew.
  5. Only after a successful signature check, compare the id from the body against identifiers you already processed. The header X-M2H-Event-Id is not signed; never use it as a duplicate or idempotency key.
Node.js (verify-webhook.js)
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);
  });
}
Node.js: use in Express
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.
Python
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
<?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_reasonMeaning
manualDisabled by PATCH with active false
delivery_failed6 attempts failed
token_revokedThe token the webhook was created (or last activated) with was revoked or deleted (immediately)
token_expiredThat token has expired
creator_lost_accessThe creator of the token is no longer active or lost the permission “API access” with full access
api_disabledThe API is switched off for the organization
A webhook lives as long as its token. For permanent integrations create a dedicated token with a long validity. Before it expires, take the webhook over with the new token: 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/webhooks regularly (e.g. daily) and raise an alarm when active is false. disabled_reason names the cause.
  • Note: with the token whose expiry disables the webhook you will not get a response with active = false after it expires, but 401 (token_expired; invalid_token for 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 …/changes from that cursor. A page has at most limit entries (default 100, at most 500): keep paging with next_cursor until it is null. Only then is the sync complete and the response delivers the new resume_cursor (on the pages before it is null). Reload the returned identifiers with …/by-ids (at most 100 per call) and process them durably. Store the new resume_cursor only 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 410 cursor_expired.
  • Otherwise get a new starting point with …/changes/cursor first, then fetch the list in full. Per request (from up to and including to) 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 400 range_too_large. Therefore fetch in consecutive time windows, all pages per window; the next window starts on the day after the to of the previous one. Fetch documents and emails with period=all. Then use …/changes from the starting cursor to catch up on whatever changed in the meantime (again page with next_cursor until null, reload and process the identifiers, and store the delivered resume_cursor only 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/cursor or /v1/damages/changes/cursor, full fetch with period=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-ids returns the list shape without documents; get the document IDs with GET /v1/damages/{id}. The former query since a point in time (from or updated_since) is still possible but no longer recommended: it does not see deletions. If a call keeps returning 410 cursor_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).

Starter project: A runnable receiver project with signature verification, inbox table and reconciliation through the lists and the change sync is available as a ZIP download (no sign-in, README with quickstart and limits, example code under the MIT license; the API itself is subject to the terms of use).

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:

  1. Fix the cause and re-enable the webhook: PATCH /v1/webhooks/{id} with { "active": true }.
  2. Queue the missed messages again with POST /v1/webhooks/{id}/deliveries/retry-failed. If the response returns remaining greater than 0, repeat the call with a new Idempotency-Key until remaining is 0.
  3. In the delivery history you see the messages as pending again; replay_count counts how often a delivery was restored.
curl: bulk call
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"}'
Response 200
{
  "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: single delivery
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)"
RuleDetails
Which messagesfailed 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 callThe 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).
PreconditionThe webhook is active and able to send (valid token). Otherwise 400 validation_error with a hint to PATCH with { "active": true }.
AttemptsAttempts 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.
LimitsAt 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.
RetryIdempotency-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.
Your system may already have processed a message, for example because it stored it but then answered with 5xx. As the event identifier stays the same, ignore duplicates by id as before.
What is not restored. The server resumes paused messages (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):

ResourceProcedureFull fetch
paymentschange streamfrom/to, ≤ 366 days
invoiceschange streamfrom/to, ≤ 1,831 days
dunning_noticeschange streamfrom/to, ≤ 1,831 days
documentschange streamperiod=all
emailschange streamperiod=all
contractschange streamperiod=all
damageschange streamperiod=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.

The collection only creates what you trigger yourself. The two requests in the folder "Idempotency-Key" create a real webhook; delete it afterwards with the third request of that folder.

Known limits

Duplicate detection. For each webhook and object (such as a contract or a payment) at most one event per type is queued while the record exists. The record is independent of the delivery history and is kept for 400 days (the delivery history itself for 30 days). If the same object triggers the same event again afterwards (for example a reset contract signature), a new event with a new 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.

  1. Token permissions: The token needs webhooks:manage and, for every subscribed event, the matching read scope (e.g. payments:read for payment.received); otherwise loading the details fails with 403. Grant only as many permissions as the program needs.
  2. Verify the signature on the raw body: Verify X-M2H-Signature over 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. with 400) and do not store them.
  3. Inbox pattern: Store the message together with its event identifier id (unique) durably and answer with 2xx only afterwards; if storing fails, answer with 5xx. Process it in a separate step and ignore duplicates by the identifier. The order of messages is not guaranteed.
  4. Durable idempotency: For writing calls, store the Idempotency-Key durably 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.
  5. 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/cursor and save it before the full fetch, (2) fetch the list completely, (3) work through …/changes from the entry cursor with next_cursor until null, and (4) save the returned resume_cursor only after processing; afterwards regularly from the saved resume_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.
  6. 429 and backoff: On 429, wait for Retry-After first; on timeout, 500 and 503 retry 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 per error.code is in the error catalog.
  7. Replay: A message is tried up to 6 times. Failed (failed) and abandoned (abandoned) deliveries can be queued again after fixing the cause with POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry or in bulk with …/deliveries/retry-failed — at most 29 days old, at most 1000 per webhook and hour, only for an active webhook.
  8. Monitoring: Check GET /v1/webhooks regularly and act on these values: active: false (disabled, reason in disabled_reason), enqueue_failures above 0 (events that could not be queued), recording_gap not null (gap in the recording: catch up through the change sync from from), pending_deliveries staying high (backlog) and paused_deliveries above 0 (paused deliveries wait for resumption). The status page shows the operational state of the platform.
  9. 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.
  10. Code generators: The webhook messages are in the openapi.json in the section webhooks (one entry per event type), not under paths. Generators that only evaluate paths create 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.