curl --fail-with-body "https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f" \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
const res = await fetch(
'https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f',
{ headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` } },
);
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
// `payload` is the exact bytes we sent, as a string — parse it yourself.
if (body.payload) {
console.log(JSON.parse(body.payload));
} else {
console.log(body.payload_state); // 'redacted' or 'purged'
}
import json
import os
import requests
res = requests.get(
"https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
timeout=30,
)
body = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
# "payload" is the exact bytes we sent, as a string — parse it yourself.
if body["payload"]:
print(json.loads(body["payload"]))
else:
print(body["payload_state"]) # "redacted" or "purged"
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
type deliveryDetail struct {
Payload *string `json:"payload"`
PayloadState string `json:"payload_state"`
}
func main() {
req, _ := http.NewRequest(http.MethodGet,
"https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
log.Fatalf("read failed: %d", res.StatusCode)
}
var body deliveryDetail
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
// Payload is the exact bytes we sent, as a string — parse it yourself.
if body.Payload != nil {
fmt.Println(*body.Payload)
} else {
fmt.Println(body.PayloadState) // "redacted" or "purged"
}
}
Read one delivery, body included
One delivery, with the body we sent you — the one read in this document where a body appears at all. The delivery IS the response; there is no wrapper object.
This is not a new disclosure: it is the same bytes, to the same recipient, that already crossed the wire and that your own logs hold. What it adds is the ability to reconcile a delivery you think you missed against what we actually sent, without opening a support ticket to ask an operator to look at a page that deliberately carries no body.
payload is the stored string, verbatim — not re-serialized from a parsed
object. That is deliberate: it is literally what crossed the wire, so you can
re-verify our signature against it, and re-serializing would silently change key
order and whitespace and break exactly that. It is null in every
payload_state but held.
404 delivery_not_found is ONE answer for “no such delivery”, “another
workspace’s delivery” and “past the retention window” — telling them apart would
confirm the existence of another workspace’s integration.
Live in a sandbox workspace. Returns 501 against a production one.
curl --fail-with-body "https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f" \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
const res = await fetch(
'https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f',
{ headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` } },
);
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
// `payload` is the exact bytes we sent, as a string — parse it yourself.
if (body.payload) {
console.log(JSON.parse(body.payload));
} else {
console.log(body.payload_state); // 'redacted' or 'purged'
}
import json
import os
import requests
res = requests.get(
"https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
timeout=30,
)
body = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
# "payload" is the exact bytes we sent, as a string — parse it yourself.
if body["payload"]:
print(json.loads(body["payload"]))
else:
print(body["payload_state"]) # "redacted" or "purged"
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
type deliveryDetail struct {
Payload *string `json:"payload"`
PayloadState string `json:"payload_state"`
}
func main() {
req, _ := http.NewRequest(http.MethodGet,
"https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
log.Fatalf("read failed: %d", res.StatusCode)
}
var body deliveryDetail
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
// Payload is the exact bytes we sent, as a string — parse it yourself.
if body.Payload != nil {
fmt.Println(*body.Payload)
} else {
fmt.Println(body.PayloadState) // "redacted" or "purged"
}
}
Authorizations
A short-lived partner token, obtained by exchanging your API key at
POST /token. Send it as Authorization: Bearer <access_token>.
Tokens live for fifteen minutes by default and never for more than an hour. Mint one per batch of work, not one per request.
The token carries the workspace and the environment its key was issued for. You cannot change either by asking: the exchange ignores everything in the request body for exactly that reason.
It also carries the key's scopes (scope, a list). Every operation names a
scope as x-maat-scope, and a token whose key lacks it is refused with
403 insufficient_scope, naming the scope. Scopes are fixed when a key is
issued and are re-read from the key on every request, so a key narrowed today is
narrowed on its next call — the token's scope is what the key held at exchange
time, for your own reading, never what authorizes a request.
x-maat-scope is the floor, and on the quick-action operations it is not the
whole requirement. PUT and DELETE /listings/{listing_id}/quick-actions/{template_key}
carry rules:write, and additionally require pricing:write when
template_key begins pricing. — the capability depends on the template in
the path, because a cleaning fee changes what a guest pays and a minimum stay
does not. The refusal is the same 403 insufficient_scope, naming
pricing:write. The two READS apply the same requirement without erroring:
GET /listings/{listing_id}/quick-actions still lists the pricing.* rows but
withholds their applied state, and GET /listings/{listing_id}/rules omits the
pricing rules entirely and reports pricing_withheld in warnings. These four
are the only operations in this document whose requirement is not exactly their
x-maat-scope, and each of them says so machine-readably:
x-maat-scope-additional lists the second scope it can require and
x-maat-scope-additional-when says what happens without it.
Never send it as a cookie or in a query string, and never store it — store the API key, mint tokens from it.
Path Parameters
Our id for the delivery, as returned by GET /webhooks/deliveries.
Response
The delivery, with its body.
One delivery, with its body — the bare object. The redrive and the test event
nest a delivery because each carries a second member beside it (the remaining
allowance); this one has nothing to sit next to.
Maat's id for this attempt record. The id redrive takes.
The event_id in the delivered body, as a UUID. Stable across retries and a redrive.
The event this delivery carries.
See WebhookEventType.
booking.confirmed, booking.cancelled, booking.requested, booking.rejected, booking.checked_in, booking.checked_out, booking.modified, review.created, listing.published, listing.unlisted, pricing.mode_changed, pricing.currency_changed, calendar.held, calendar.released Where the delivery is in its lifecycle. redacted means we kept the row and dropped the body.
See WebhookDeliveryStatus.
pending, retrying, delivering, delivered, failed, redacted How many times we have tried to deliver it. A redrive resets this to zero.
Why the last attempt failed, from a closed vocabulary, or null if none has failed.
The HTTP status your endpoint answered with on the last attempt, or null if it never answered.
When we will try again, or null when no attempt is scheduled.
The occurred_at in the delivered body. See the delivery-timestamp note on the webhook envelopes.
When Maat queued the delivery.
Whether the body is still stored. false once it has aged out or been redacted — the row survives, the body does not.
true when this delivery came from POST /webhooks/endpoint/test
rather than from something that happened. The body we POSTed
carries the same flag.
The exact bytes we sent, as a JSON STRING — not a re-parsed object.
null in every state but held.
held — we still have the exact bytes; the only state a redrive can use.
redacted — the delivery was still owed when the guest exercised their
right to erasure, so the body was destroyed and it will never be sent;
nothing is broken, there is simply nothing left to send. purged — the
body aged out of the retention window, or was destroyed by an erasure
after the delivery had already settled. Either way, nothing is left to
send.
See WebhookPayloadState.
held, redacted, purged The signature headers the LAST attempt carried, exactly as we sent them:
X-Signature, X-Signature-Timestamp, and X-Signature-Previous when a
rotation overlap was open. That third key's ABSENCE is itself a fact — no
second signature was sent — and not an omission.
With the byte-exact payload beside them you have the complete input to
your own verifier, so a failure is diagnosable without asking anyone:
a wrong secret, a wrong canonical string and a clock look different once
you can compare. The values were already on the wire and are already in
your own access log; this is the same disclosure to the same party.
Recomputed on read from the timestamp and the secret generations the
attempt recorded — the signing secret is never stored and is never
returned here. POST /webhooks/endpoint/rotate-secret is the only
thing that ever returns a secret, once.
null in three honest cases: nothing was ever attempted; the body is
gone (payload_state is redacted or purged), so a signature over
bytes we no longer have cannot be rebuilt; or the platform cannot sign at
all right now.
Show child attributes
Show child attributes
{ "X-Signature-Timestamp": "1789200000", "X-Signature": "b9eeb16c320e853232bad7b7200b69a4bfa3abce6580e1de5025a6c441e4fdd6" }