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.
| Header | What it says |
|---|---|
X-Maci-Event | The event, for example alarm.raised. The same as event in the body. |
X-Maci-Signature | sha256= and the HMAC-SHA256 of the raw body, made with the webhook's secret, in hex. |
Content-Type | application/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.
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $_SERVER['HTTP_X_MACI_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
const crypto = require('crypto');
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const given = req.get('X-Maci-Signature') || '';
const ok = given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
import hashlib
import hmac
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers.get("X-Maci-Signature", ""))
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.idandeventto 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.
-
eventstring requiredThe same as the header
X-Maci-Event.One of
alarm.raised,alarm.cleared,alarm.acknowledged,alarm.closed -
created_atstring (date-time) requiredWhen the event was sent.
-
dataobject requiredSee the alarm model.
{
"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.
-
eventstring requiredThe same as the header
X-Maci-Event.One of
invoice_draft.approved -
created_atstring (date-time) requiredWhen the event was sent.
-
dataobject required
{
"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.