# 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](https://app.maci.energy/organization/api-keys), 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.

```php
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret);

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

```javascript
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));
```

```python
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.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.

```json
{
  "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](https://api.maci.energy/docs/alarms.md#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.

```json
{
  "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](https://api.maci.energy/docs/invoicing.md#the-invoice-draft-model).
