Skip to content

Guides

Webhooks

Maci can post events to an address of your own, so your system hears about an alarm or an approved invoice basis at once, signed with a secret only you and Maci know.

Setting up a webhook

Your system does not have to keep asking the API. Set an address of your own in the app, and Maci posts each event to it as it happens. Each organisation sets its own address and gets a secret: the address for alarms under API-nøgler, the one for invoice bases under Fakturagrundlag, Indstillinger.

HeaderWhat it says
X-Maci-EventThe event, for example alarm.raised. The same as event in the body.
X-Maci-Signaturesha256= and the HMAC-SHA256 of the raw body, made with the webhook's secret, in hex.
Content-Typeapplication/json

Checking the signature

Make the HMAC-SHA256 of the body exactly as it arrived, before any JSON parsing, with the secret, and compare it with X-Maci-Signature in constant time. If they differ, the call is not from Maci.

Check the signature
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret);

if (! hash_equals($expected, $_SERVER['HTTP_X_MACI_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

Answering and retries

  • The address must be https and on the public internet. Redirects are not followed.
  • Answer with a 2xx within 10 seconds and do the work afterwards. Anything else is tried again, with the pauses given for each webhook below.
  • An event can arrive more than once, for example when your answer came too late. Use data.id and event to recognise a repeat.
  • Everything a webhook sends can also be fetched with the API, so a missed event can be caught up.

Alarm webhook

An alarm's event, posted to the organisation's webhook address for alarms. Set the address and get the secret under API keys in the app. X-Maci-Event is alarm.raised, alarm.cleared, alarm.acknowledged or alarm.closed, and data is the alarm as GET /alarms/{alarm} gives it at the moment it is sent. Answer with a 2xx within 10 seconds; anything else is tried again after 10 seconds, 30 seconds, 2 minutes, 10 minutes and 30 minutes. The SMS and mail to the recipients do not depend on the webhook, and the alarms can always be fetched with GET /alarms.

Events: alarm.raised, alarm.cleared, alarm.acknowledged, alarm.closed.

  • event string required

    The same as the header X-Maci-Event.

    One of alarm.raised, alarm.cleared, alarm.acknowledged, alarm.closed

  • created_at string (date-time) required

    When the event was sent.

  • data object required

    See the alarm model.

Body
{
  "event": "alarm.raised",
  "created_at": "2026-10-30T02:14:07Z",
  "data": {
    "id": "7e3b1c90-2f4a-4d8e-9b61-5a0c3d7f2e18",
    "unit_id": "0b6f4c1e-5a1d-4a57-9f0e-2f6f3f0c9a11",
    "unit_public_id": "MC-0482",
    "unit": "Cold room 2",
    "site": "Main store",
    "type": "rum-for-varmt",
    "name": "Room too warm",
    "severity": "HIGH",
    "state": "raised",
    "open": true,
    "values": {
      "room_temp_c": "9.4"
    },
    "raised_at": "2026-10-30T02:14:05Z",
    "cleared_at": null,
    "level": 0,
    "watch": null,
    "acknowledged_at": null,
    "acknowledged_by": null,
    "closed_at": null,
    "closed_by": null,
    "result": null,
    "note": null
  }
}

The fields of data are those of the alarm model.

Invoice basis webhook

An approved invoice basis, posted to the service company's webhook address. Set the address and get the secret under Invoice basis, Settings, in the app. X-Maci-Event is invoice_draft.approved, and data is the invoice basis as GET /invoice-drafts/{draft} gives it. Answer with a 2xx within 10 seconds; anything else is tried again after 1 minute, 5 minutes, 30 minutes and 2 hours. The bases can always be fetched with GET /invoice-drafts as well.

Events: invoice_draft.approved.

  • event string required

    The same as the header X-Maci-Event.

    One of invoice_draft.approved

  • created_at string (date-time) required

    When the event was sent.

  • data object required

    See the invoice draft model.

Body
{
  "event": "invoice_draft.approved",
  "created_at": "2026-10-30T14:02:51Z",
  "data": {
    "id": "3c8d2a71-6e0b-4f95-a2d4-8b1e7c5f0a93",
    "status": "approved",
    "service_report_id": "9a4f0e2b-7c13-4d68-b5e1-2d9c6a3f8b07",
    "unit_id": "0b6f4c1e-5a1d-4a57-9f0e-2f6f3f0c9a11",
    "unit_public_id": "MC-0482",
    "unit": "Cold room 2",
    "customer": {
      "name": "Example Foods ApS",
      "cvr": "12345678",
      "vat_number": null
    },
    "visit_date": "2026-10-30",
    "technician": "Jonas L.",
    "currency": "DKK",
    "total_excl_vat": 1890,
    "approved_at": "2026-10-30T14:02:49Z",
    "exported_at": null,
    "lines": [
      {
        "type": "labour",
        "item_number": "1000",
        "description": "Labour",
        "quantity": 1.5,
        "unit_of_measure": "hours",
        "unit_price": 700,
        "amount_excl_vat": 1050,
        "source": "speech",
        "match_sure": true
      },
      {
        "type": "travel",
        "item_number": "1010",
        "description": "Call-out",
        "quantity": 1,
        "unit_of_measure": "pcs",
        "unit_price": 840,
        "amount_excl_vat": 840,
        "source": "speech",
        "match_sure": true
      }
    ]
  }
}

The fields of data are those of the invoice draft model.