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:httpsonly, 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 genericvalidation_failed; we do not report which check failed.- One endpoint per workspace. Registering again replaces the URL and the event list.
An empty
eventsarray 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 signedendpoint.challenge delivery to your URL, and your
handler must answer 2xx with a JSON body echoing the same string back:
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:
Four rules, in order of how often they are gotten wrong:
- Verify against the raw request body, before any JSON parsing.
JSON.parsefollowed byJSON.stringifydoes not round-trip byte-identically — key order, whitespace and1.0→1all move — so a re-serialised body fails verification every time. Capture the bytes as they arrived. - 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.
- Compare in constant time, using your language’s timing-safe comparison — never
==on the hex strings. - During a rotation, accept either signature.
X-Signature-Previousis 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.
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_idis stable across retries — the same event redelivered carries the same id, so it is your idempotency key. Process each id once and answer duplicates200.- Order by
occurred_at, never by arrival.occurred_atis 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:
- 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.
- When the guest erases their account, you delete. See below.
- 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.
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 auser.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 page.