> ## Documentation Index
> Fetch the complete documentation index at: https://developer.maatstays.wtf/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Signed deliveries to one endpoint you register: bookings, cancellations, and erasure requests.

Everything Maat pushes to you — booking notifications and erasure requests — arrives as
a signed HTTPS POST to **one endpoint** you register with `PUT /v1/webhooks/endpoint`.
This page is the part of your integration that most rewards care: get verification
right and every delivery you act on is provably ours; get it wrong and anyone who finds
your endpoint URL can feed your system fake bookings.

## Registering your endpoint

Send the URL and the events you want:

```json theme={null}
{
    "url": "https://hooks.example-partner.invalid/maat",
    "events": ["booking.confirmed", "booking.cancelled", "user.erased"]
}
```

* **`https` only, on port 443 or 8443, on a publicly resolvable DNS name.** We check the
  address the name actually resolves to at the moment we connect — not just at
  registration — and we never follow redirects. A refused URL gets one generic
  `validation_failed`; we do not report which check failed.
* **One endpoint per workspace.** Registering again replaces the URL and the event list.
  An empty `events` array pauses delivery without discarding the endpoint or its secret.
* **The signing secret is returned once**, in the response to your *first* registration,
  and never again — we cannot read it back. Store it in a secret manager immediately.
  Re-registering (a corrected path, a new event list) deliberately does **not** mint a
  new secret, so your existing verification keeps working.

### The challenge round-trip

Nothing real is delivered until your endpoint proves it is yours. During the
registration call we POST a signed `endpoint.challenge` delivery to your URL, and your
handler must answer `2xx` with a JSON body echoing the same string back:

```json theme={null}
{ "challenge": "<the value we sent>" }
```

Echo it and the endpoint becomes `active`. Anything else leaves it
`pending_verification` and nothing is ever sent to it — call the registration endpoint
again to retry. The challenge is signed **exactly like a real delivery**, so your
verification code is exercised before your first real payload rather than after it.
Respond within five seconds; the challenge happens inside your own registration
request. And expect it on *every* registration call, including one that does not change
the URL.

## Verifying a delivery

Every delivery carries three headers:

| Header                  | What it is                                                                     |
| :---------------------- | :----------------------------------------------------------------------------- |
| `X-Signature`           | `HMAC-SHA256(secret, "v1:" + timestamp + ":" + raw_body)`, hex-encoded.        |
| `X-Signature-Timestamp` | Unix seconds. Covered *by* the signature — reject anything over 5 minutes old. |
| `X-Signature-Previous`  | The same body signed with your previous secret. Rotation windows only.         |

Four rules, in order of how often they are gotten wrong:

1. **Verify against the raw request body, before any JSON parsing.** `JSON.parse`
   followed by `JSON.stringify` does not round-trip byte-identically — key order,
   whitespace and `1.0` → `1` all move — so a re-serialised body fails verification
   every time. Capture the bytes as they arrived.
2. **Reject a timestamp more than five minutes old** before comparing anything. The
   timestamp is inside the signed string, so a replayed body cannot carry a fresh one.
3. **Compare in constant time**, using your language's timing-safe comparison — never
   `==` on the hex strings.
4. **During a rotation, accept either signature.** `X-Signature-Previous` is present
   only while a rotation overlap is open; its absence is normal, not an error. If you
   lose your secret or it leaks, ask us to rotate: we sign with both secrets for an
   overlap window so you deploy on your own clock, never against ours.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import { createHmac, timingSafeEqual } from 'node:crypto';
  import express from 'express';

  const app = express();
  const secret = process.env.MAAT_WEBHOOK_SECRET;

  // express.raw, not express.json — verification needs the raw bytes.
  app.post('/maat', express.raw({ type: 'application/json' }), (req, res) => {
      const timestamp = req.header('X-Signature-Timestamp') ?? '';

      // 1. Reject a stale timestamp before comparing anything.
      if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
          return res.status(401).end();
      }

      // 2. HMAC over "v1:" + timestamp + ":" + the raw body.
      const expected = createHmac('sha256', secret)
          .update(`v1:${timestamp}:`)
          .update(req.body) // a Buffer — the bytes as delivered
          .digest('hex');

      // 3. Constant-time compare — against either signature during a rotation.
      const matches = (candidate) =>
          typeof candidate === 'string' &&
          candidate.length === expected.length &&
          timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));

      const verified =
          matches(req.header('X-Signature')) || matches(req.header('X-Signature-Previous'));
      if (!verified) return res.status(401).end();

      // 4. Only now parse.
      const event = JSON.parse(req.body.toString('utf8'));

      // 5. event_id is stable across our retries — use it as your idempotency key.
      //    A duplicate you have already processed should be answered 200 and ignored.

      if (event.event_type === 'endpoint.challenge') {
          return res.status(200).json({ challenge: event.data.challenge });
      }

      res.status(200).end();
  });

  app.listen(8443);
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import os
  import time

  from flask import Flask, jsonify, request

  app = Flask(__name__)
  SECRET = os.environ["MAAT_WEBHOOK_SECRET"].encode()


  @app.post("/maat")
  def maat_webhook():
      raw_body = request.get_data()  # the raw bytes, before any JSON parsing
      timestamp = request.headers.get("X-Signature-Timestamp", "")

      # 1. Reject a stale timestamp before comparing anything.
      if not timestamp or abs(time.time() - int(timestamp)) > 300:
          return "", 401

      # 2. HMAC over b"v1:" + timestamp + b":" + the raw body.
      expected = hmac.new(
          SECRET, b"v1:" + timestamp.encode() + b":" + raw_body, hashlib.sha256
      ).hexdigest()

      # 3. Constant-time compare — against either signature during a rotation.
      candidates = [
          request.headers.get("X-Signature", ""),
          request.headers.get("X-Signature-Previous", ""),
      ]
      if not any(sig and hmac.compare_digest(sig, expected) for sig in candidates):
          return "", 401

      # 4. Only now parse.
      event = request.get_json(force=True)

      # 5. event_id is stable across retries — use it as your idempotency key.

      if event["event_type"] == "endpoint.challenge":
          return jsonify(challenge=event["data"]["challenge"])

      return "", 200
  ```
</CodeGroup>

## Retries, idempotency and ordering

* **Acknowledge with any `2xx`.** Anything else is retried with exponential backoff;
  a delivery we eventually abandon is surfaced to our operators rather than silently
  dropped.
* **`event_id` is stable across retries** — the same event redelivered carries the same
  id, so it is your idempotency key. Process each id once and answer duplicates `200`.
* **Order by `occurred_at`, never by arrival.** `occurred_at` is when the event
  happened, not when we sent it, and retries reorder deliveries.
* **We never follow redirects.** If your endpoint moves, register the new URL.

## What a booking notification carries — and what it never will

`booking.confirmed` is the arrival-management and reconciliation payload: the booking
reference, the property, the stay dates with the property's own check-in and check-out
times, the party size, the guest's **name**, and `amount_paid` as one
`{ value, currency }` object (`value` is a decimal string — parse it with a decimal
type, not a float).

Receiving that payload makes you a recipient of personal data, which carries three
plain obligations:

1. **What you receive is bounded.** The guest's name and nothing else about them — no
   email, no phone, no durable guest identifier. Their absence is a decision, not an
   oversight: the guest booked on Maat and Maat carries the pre-arrival conversation.
   If you need to reach a guest, reach the property. Use of the data is governed by
   your data-sharing agreement.
2. **When the guest erases their account, you delete.** See below.
3. **You never send guest data back on the calendar direction.** A block carries dates
   and a closed `reason` — nothing else, enforced by the schema. See
   [Calendar blocks](/guides/calendar-blocks).

`booking.cancelled` carries the same booking reference plus `cancelled_at` and
`initiated_by` — `guest` means the nights are free to re-sell, `host` means the
property withdrew and somebody has to be re-accommodated. It repeats neither the
guest's name (you have it from the confirmation you are correlating against) nor any
money: refund terms are between Maat, the guest and the property.

## Erasure requests

When somebody whose booking details you received erases their Maat account, you get a
`user.erased` delivery listing the records to delete — as booking references and your
own provider ids. **The payload deliberately contains no personal data at all**, not
even a user id: you already hold everything else from the notifications we sent, and
restating a person's details at the moment they are being destroyed would write them
into your delivery log.

Delete the named records from your systems, then confirm with
`POST /v1/deletion-requests/{event_id}/acknowledge` — using the delivery's `event_id`.
Call it only when the deletion is actually done: there is no failure state to park a
request in, and an unacknowledged request escalates on our side. Your acknowledgment
never delays Maat's own erasure, which completes regardless.

The current availability of each event and endpoint is stated on its
[API reference](/api-reference/properties/create-or-replace-a-property) page.
