Developers
Read your community’s amenities and bookings, check availability, approve, decline or cancel requests, and receive signed events when a booking changes.
On this page
Before you start
The API works on one community at a time. A key belongs to a community, and every request answers for that community only. There is nothing to choose in the request, and an id from another community is reported as not found.
- It comes with Amenity Gold and above. On a lower plan a key cannot be made, and an existing key answers
plan_required. - Only the community’s main account makes, rolls and revokes keys. No team seat can be given that permission. Better Seasons staff can also revoke a key that has leaked.
- Keys are for servers. A request that carries a browser
OriginorSec-Fetch-Siteheader is refused, and no CORS header is ever sent.
Keys and authorization
Sign in to Amenity Reserve and open Settings, choose Open API keys, then Make a key. Name it, and tick the scopes it needs. Making, rolling and revoking a key each ask for a fresh second-factor code. The full key is shown once, in that dialog, and cannot be shown again. Only a hash of it is stored. A community can hold up to ten live keys, and the list shows each key’s creator, last use and expiry. Rolling a key makes a new one and lets the old one run on for a short overlap. Revoking one takes effect on the next request.
A key looks like this, with a fixed prefix so secret scanners can find a leaked one. Keys made in a test deployment start with ar_test_ and work only there.
ar_live_7q2mk4x9hv3b_k3Jz9nQe0W1c8Yt5uR2bXoP6dLsA4vGh7mFiNwZ3xK9
^ar_(live|test)_([0-9a-hjkmnp-tv-z]{12})_([A-Za-z0-9_-]{43})$Send the key as a bearer token on every request.
curl "https://app.amenityreserve.com/api/v1/bookings?status=pending" \
-H "Authorization: Bearer ar_live_<your key>"A key that is missing, malformed, unknown, revoked, expired or from the wrong deployment gets the same invalid_key answer. A key can do only what its scopes allow, and scopes cannot be changed after the key is made. Make a new key instead.
| Scope | What it opens |
|---|---|
amenities:read | Read amenities. Spaces, their booking rules and the times already taken. |
bookings:read | Read bookings. Requests and bookings, with each member shown as a first name and a last initial. |
bookings:write | Approve, decline and cancel. Answer requests and cancel bookings that have no payment on them. It also shows the notes admins wrote. |
members:read | Read members. The names, email addresses and street addresses of everyone on your list. |
webhooks:manage | Manage webhooks. Let the tool subscribe to booking events. |
Requests and responses
- Every response is JSON, is never cached, and carries an
AR-Request-Idheader. Quote it when you ask us about a request. - Request bodies are JSON objects of at most 16 KB. Unknown fields are ignored. A wrongly typed field is a
bad_paramerror. - Times are RFC 3339 in UTC, such as
2026-11-02T18:00:00Z. Each amenity carries its owntimezonefor display. - Within v1 changes only add fields, endpoints, event types and enum values. Ignore fields you do not know.
- No endpoint creates a booking. Software can check when a space is free with the availability endpoint, and members book in Amenity Reserve. The only changes the API makes are to approve, decline or cancel an existing booking.
- There are no money endpoints. A booking with a payment attached cannot be approved, declined or cancelled through the API: it answers
payment_needs_dashboard.
Pagination
Lists take limit and cursor. Each page answers with has_more and next_cursor. Send next_cursor back as cursor to get the next page, and stop when has_more is false. A cursor is opaque. It works only for the list and community that made it, and anything else is a bad_cursor error.
{
"data": [
"…"
],
"has_more": true,
"next_cursor": "eyJ2IjoxLC…"
}Errors
An error has a status, a type, a code to branch on, and a plain sentence for people. param appears when one field or scope is to blame.
{
"error": {
"type": "permission",
"code": "missing_scope",
"message": "This key cannot do that. It needs the members:read scope.",
"param": "members:read",
"request_id": "req_01JC8M4Q2R6T0V3X5Z7B9D1F3H"
}
}| Status | Type | Code | Message |
|---|---|---|---|
| 400 | invalid_request | bad_json | The body must be a JSON object. |
| 400 | invalid_request | bad_param | limit must be a whole number from 1 to 100. |
| 400 | invalid_request | bad_cursor | That cursor is not valid for this list. |
| 400 | invalid_request | body_too_large | The body can be at most 16 KB. |
| 400 | invalid_request | idempotency_key_required | Send an Idempotency-Key header of 1 to 255 printable characters. |
| 401 | authentication | invalid_key | This key is missing, wrong, expired or revoked. |
| 403 | permission | missing_scope | This key cannot do that. It needs the members:read scope. |
| 403 | permission | plan_required | This community’s plan does not include the API. |
| 403 | permission | browser_not_allowed | Send API requests from a server, not from a browser. |
| 403 | permission | community_unverified | This community has not been verified yet. |
| 403 | permission | payment_needs_dashboard | This booking has a payment. Approve, decline or cancel it in the Amenity Reserve dashboard. |
| 404 | not_found | not_found | That does not exist. |
| 409 | conflict | state | The booking is not waiting for an answer. |
| 409 | conflict | idempotency_in_progress | A request with that Idempotency-Key is still running. Wait a moment and send it again. |
| 409 | conflict | community_leaving | This community is leaving Amenity Reserve. |
| 422 | invalid_request | idempotency_key_reused | That Idempotency-Key was already used with a different request. |
| 429 | rate_limit | rate_limited | Too many requests. Wait a moment and try again. |
| 500 | api_error | internal | Something went wrong on our side. |
| 503 | api_error | unavailable | The API cannot answer just now. Try again in a moment. |
A 5xx answer carries no detail. The request id is enough for us to find it.
Idempotency-Key
Every POST needs an Idempotency-Key header of 1 to 255 printable characters. Send the same key and the same request to retry safely: the first answer comes back with an Idempotent-Replayed: true header, and nothing happens twice. The same key with a different request is idempotency_key_reused. A key whose first request is still running is idempotency_in_progress. Answers are kept for 24 hours. A 5xx answer is not kept, so a retry runs again.
curl -X POST https://app.amenityreserve.com/api/v1/bookings/9b1d7e44-2c0a-4f6b-8e3d-5a7f1c2e9d01/approve \
-H "Authorization: Bearer ar_live_<your key>" \
-H "Idempotency-Key: approve-9b1d7e44-2c0a-4f6b-8e3d-5a7f1c2e9d01" \
-H "Content-Type: application/json" \
-d '{"note":"Approved."}'Rate limits
| Limit | Requests |
|---|---|
| Reads, per key | 120 every minute |
| Writes, per key | 30 every minute |
| All keys of one community | 600 every minute |
| All requests from one IP address | 300 every minute |
| Requests with a wrong key, per IP address | 20 every 10 minutes |
Over a limit the answer is 429 rate_limited with Retry-After, RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Wait the number of seconds in Retry-After. The windows are fixed, not sliding.
Endpoints
Paths are relative to https://app.amenityreserve.com/api/v1.
GET /me
The community and the key making the request. Any valid key may call it, so it works as a connection test.
{
"community": {
"id": "0a7e3c11-8d24-4b6f-9c05-2e1f7a6b3d90",
"name": "Robins Drive HOA",
"kind": "association",
"timezone": "America/Chicago",
"plan": "Amenity Gold"
},
"key": {
"id": "e8b2f6a0-3c14-4a9d-8f70-1d5c9b2a7e33",
"kid": "7q2mk4x9hv3b",
"name": "Zapier",
"scopes": [
"bookings:read"
],
"mode": "live"
}
}GET /amenities
The community’s spaces that are set up, with the booking-shape rules for each, by name.
limitquery Page size from 1 to 100. The default is 50.cursorquery The next_cursor from the previous page.
{
"data": [
{
"id": "3f6c0d52-7a1b-4e0e-9a55-6d1c1b0f8a11",
"object": "amenity",
"name": "Clubhouse",
"template": "clubhouse",
"status": "published",
"timezone": "America/Chicago",
"rules": {
"needs_approval": true,
"min_minutes": 60,
"max_minutes": 240,
"max_headcount": 50,
"advance_days": 90,
"rules_version": 4
}
}
],
"has_more": false,
"next_cursor": null
}GET /amenities/{id}
One space.
idpath, required The amenity id.
{
"id": "3f6c0d52-7a1b-4e0e-9a55-6d1c1b0f8a11",
"object": "amenity",
"name": "Clubhouse",
"template": "clubhouse",
"status": "published",
"timezone": "America/Chicago",
"rules": {
"needs_approval": true,
"min_minutes": 60,
"max_minutes": 240,
"max_headcount": 50,
"advance_days": 90,
"rules_version": 4
}
}GET /amenities/{id}/availability
The times already held for a space, with no names. It shows what a member sees.
idpath, required The amenity id.fromquery, required RFC 3339 time.toquery, required RFC 3339 time after from, at most 31 days later.
{
"busy": [
{
"starts_at": "2026-11-02T18:00:00Z",
"ends_at": "2026-11-02T22:00:00Z"
}
]
}GET /bookings
Bookings and requests, newest first. Each member shows as a first name and a last initial.
statusquery pending, confirmed, declined, cancelled or completed. Repeat the parameter to ask for several.amenity_idquery Only this space.starts_afterquery RFC 3339 time.starts_beforequery RFC 3339 time.updated_sincequery RFC 3339 time.orderquery starts_at lists by start time, earliest first. Without it the list is newest first.expandquery resident adds the member’s name, email, address and role. It needs the members:read scope.limitquery Page size from 1 to 100. The default is 50.cursorquery The next_cursor from the previous page.
{
"data": [
{
"id": "9b1d7e44-2c0a-4f6b-8e3d-5a7f1c2e9d01",
"object": "booking",
"amenity_id": "3f6c0d52-7a1b-4e0e-9a55-6d1c1b0f8a11",
"status": "pending",
"starts_at": "2026-11-02T18:00:00Z",
"ends_at": "2026-11-02T22:00:00Z",
"headcount": 40,
"rules_version": 4,
"answers": {
"purpose": "Birthday",
"alcohol": false
},
"payment_status": "none",
"resident": {
"id": "5d2e8a90-1b3c-4d7e-a6f1-0c9b8a7d6e52",
"display_name": "Dana K."
},
"agreement": {
"mode": "checkbox",
"signed_at": "2026-10-28T15:02:11Z"
},
"decided_at": null,
"cancelled_at": null,
"cancelled_by": null,
"time_changes": 0,
"created_at": "2026-10-28T15:02:11Z",
"updated_at": "2026-10-28T15:02:11Z"
}
],
"has_more": true,
"next_cursor": "eyJ2IjoxLCJvIjoiY3JlYXRlZF9hdCIsImsiOlsiMjAyNi0xMC0yOFQxNTowMjoxMS4wMDAwMDBaIiwiOWIxZDdlNDQtMmMwYS00ZjZiLThlM2QtNWE3ZjFjMmU5ZDAxIl0sImEiOiI0YzFlOWYyYSJ9"
}GET /bookings/{id}
One booking.
idpath, required The booking id.expandquery resident, as above.
{
"id": "9b1d7e44-2c0a-4f6b-8e3d-5a7f1c2e9d01",
"object": "booking",
"amenity_id": "3f6c0d52-7a1b-4e0e-9a55-6d1c1b0f8a11",
"status": "pending",
"starts_at": "2026-11-02T18:00:00Z",
"ends_at": "2026-11-02T22:00:00Z",
"headcount": 40,
"rules_version": 4,
"answers": {
"purpose": "Birthday",
"alcohol": false
},
"payment_status": "none",
"resident": {
"id": "5d2e8a90-1b3c-4d7e-a6f1-0c9b8a7d6e52",
"display_name": "Dana K."
},
"agreement": {
"mode": "checkbox",
"signed_at": "2026-10-28T15:02:11Z"
},
"decided_at": null,
"cancelled_at": null,
"cancelled_by": null,
"time_changes": 0,
"created_at": "2026-10-28T15:02:11Z",
"updated_at": "2026-10-28T15:02:11Z"
}POST /bookings/{id}/approve
Approve a pending request. The member and the attendant are mailed exactly as when an admin approves in the dashboard.
idpath, required The booking id.Idempotency-Keyheader, required One to 255 printable characters. Send the same key again to retry the same request.notebody Optional text of up to 500 characters.
{
"id": "9b1d7e44-2c0a-4f6b-8e3d-5a7f1c2e9d01",
"object": "booking",
"status": "confirmed"
}POST /bookings/{id}/decline
Decline a pending request.
idpath, required The booking id.Idempotency-Keyheader, required One to 255 printable characters. Send the same key again to retry the same request.notebody Optional text of up to 500 characters.
{
"id": "9b1d7e44-2c0a-4f6b-8e3d-5a7f1c2e9d01",
"object": "booking",
"status": "declined"
}POST /bookings/{id}/cancel
Cancel a pending or confirmed booking.
idpath, required The booking id.Idempotency-Keyheader, required One to 255 printable characters. Send the same key again to retry the same request.reasonbody Optional text of up to 500 characters.
{
"id": "9b1d7e44-2c0a-4f6b-8e3d-5a7f1c2e9d01",
"object": "booking",
"status": "cancelled"
}GET /members
The community’s member list, newest first.
emailquery An exact address. This is the find-a-member search.standingquery active, suspended or inactive.updated_sincequery RFC 3339 time.include_archivedquery true to include archived members. They are left out by default.limitquery Page size from 1 to 100. The default is 50.cursorquery The next_cursor from the previous page.
{
"data": [
{
"id": "5d2e8a90-1b3c-4d7e-a6f1-0c9b8a7d6e52",
"object": "member",
"name": "Dana Kim",
"email": "dana@example.com",
"address": "12 Robins Dr",
"household_id": "b7e1c0a94d2f",
"role": "owner",
"standing": "active",
"verified": true,
"archived": false,
"created_at": "2026-09-01T12:00:00Z"
}
],
"has_more": true,
"next_cursor": "eyJ2IjoxLCJvIjoiY3JlYXRlZF9hdCIsImsiOlsiMjAyNi0xMC0yOFQxNTowMjoxMS4wMDAwMDBaIiwiOWIxZDdlNDQtMmMwYS00ZjZiLThlM2QtNWE3ZjFjMmU5ZDAxIl0sImEiOiI0YzFlOWYyYSJ9"
}GET /members/{id}
One member.
idpath, required The member id.
{
"id": "5d2e8a90-1b3c-4d7e-a6f1-0c9b8a7d6e52",
"object": "member",
"name": "Dana Kim",
"email": "dana@example.com",
"address": "12 Robins Dr",
"household_id": "b7e1c0a94d2f",
"role": "owner",
"standing": "active",
"verified": true,
"archived": false,
"created_at": "2026-09-01T12:00:00Z"
}Webhook endpoints
These manage where events are sent. Adding one needs a plan that includes the API. A community can have 10 endpoints turned on, can add or roll 10 signing secrets a day, and each endpoint can be tested 5 times a minute.
GET /webhook_endpoints
The community’s webhook endpoints that are turned on. Only the host of each URL is shown.
{
"data": [
{
"id": "c41a7b30-5e92-4d08-b1f6-7a3e9d2c8f64",
"object": "webhook_endpoint",
"url_host": "hooks.example.com",
"events": [
"booking.created",
"booking.approved"
],
"status": "enabled",
"disabled_reason": null,
"source": "api",
"created_at": "2026-10-28T15:00:00Z"
}
],
"has_more": false,
"next_cursor": null
}POST /webhook_endpoints
Add an endpoint. Only the community’s main account can make the key that calls this. The response holds its signing secret once, and it cannot be fetched again.
Idempotency-Keyheader, required One to 255 printable characters. Send the same key again to retry the same request.urlbody, required An https address on a public host.eventsbody, required A list of event names.
{
"id": "c41a7b30-5e92-4d08-b1f6-7a3e9d2c8f64",
"object": "webhook_endpoint",
"url_host": "hooks.example.com",
"events": [
"booking.created",
"booking.approved"
],
"status": "enabled",
"disabled_reason": null,
"source": "api",
"created_at": "2026-10-28T15:00:00Z",
"secret": "whsec_Zr4mQ9v2LxT7cNf0HkB5uWjE8sYdA1pG3oIeVn6RtXw"
}DELETE /webhook_endpoints/{id}
Turn an endpoint off for good and drop what was queued for it.
idpath, required The endpoint id.
{
"id": "c41a7b30-5e92-4d08-b1f6-7a3e9d2c8f64",
"object": "webhook_endpoint",
"deleted": true
}POST /webhook_endpoints/{id}/test
Queue a ping event to the endpoint.
idpath, required The endpoint id.Idempotency-Keyheader, required One to 255 printable characters. Send the same key again to retry the same request.
{
"id": "c41a7b30-5e92-4d08-b1f6-7a3e9d2c8f64",
"object": "webhook_endpoint",
"test": "queued",
"event_id": "evt_01JC8M4Q2R6T0V3X5Z7B9D1F3H"
}An address is refused when it is not https on the default port, carries a username, names an IP address, points at one of our own domains, or resolves to a private network. The error code says which:
bad_urlThat is not a web address we can send to.not_httpsThe address must start with https://.portOnly the standard https port is allowed.credentialsThe address cannot contain a user name or password.ip_literalUse a host name, not an IP address.own_domainThat address belongs to Amenity Reserve or its partners, so we cannot send there.internal_nameThat host name is not a public address.
Webhooks
A webhook is a signed POST to your endpoint when a booking changes. The events are:
| Event | Sent when |
|---|---|
booking.created | Sent when a member asks for a space, or books one that needs no approval. |
booking.approved | Sent when an admin approves a request. |
booking.declined | Sent when an admin declines a request. |
booking.cancelled | Sent when a member or an admin cancels, with who did it. |
booking.changed | Sent when an admin moves a booking, with the old time. |
ping | Sent only when you call the test endpoint. Its data is empty. |
The body is the booking as it is at the moment of sending, in the same shape the API returns, with the member shown as a first name and a last initial. previous holds what changed. api_version is 2026-11-01.
{
"id": "evt_01JC8M4Q2R6T0V3X5Z7B9D1F3H",
"type": "booking.cancelled",
"api_version": "2026-11-01",
"created": "2026-10-29T14:05:09Z",
"community_id": "0a7e3c11-8d24-4b6f-9c05-2e1f7a6b3d90",
"data": {
"object": {
"id": "9b1d7e44-2c0a-4f6b-8e3d-5a7f1c2e9d01",
"object": "booking",
"amenity_id": "3f6c0d52-7a1b-4e0e-9a55-6d1c1b0f8a11",
"status": "cancelled",
"starts_at": "2026-11-02T18:00:00Z",
"ends_at": "2026-11-02T22:00:00Z",
"headcount": 40,
"rules_version": 4,
"answers": {
"purpose": "Birthday",
"alcohol": false
},
"payment_status": "none",
"resident": {
"id": "5d2e8a90-1b3c-4d7e-a6f1-0c9b8a7d6e52",
"display_name": "Dana K."
},
"agreement": {
"mode": "checkbox",
"signed_at": "2026-10-28T15:02:11Z"
},
"decided_at": null,
"cancelled_at": "2026-10-29T14:05:09Z",
"cancelled_by": "admin",
"time_changes": 0,
"created_at": "2026-10-28T15:02:11Z",
"updated_at": "2026-10-28T15:02:11Z"
},
"previous": {
"status": "confirmed"
}
}
}Each delivery carries these headers:
AR-Event-Id: evt_01JC8M4Q2R6T0V3X5Z7B9D1F3H
AR-Event-Type: booking.cancelled
AR-Signature: t=1793642400,v1=1d259a7ecdc4863822b1cbb5989862afca69eae1f11266c56c2343d5c7637f69
Content-Type: application/json
User-Agent: AmenityReserve-Webhooks/1Delivery
- Delivery is at least once. The same event can arrive more than once, so dedupe on the
idin the signed body. TheAR-Event-Idheader is not covered by the signature and is for your logs. - Answer with any 2xx within 10 seconds. A redirect counts as a failure and is not followed. A 410 turns the endpoint off at once.
- At most 2 deliveries to one endpoint are in flight at a time. Order is not promised.
- Anything else is retried, up to 9 attempts over about three days. Each retry is signed again.
- An endpoint is turned off after 50 failures in a row once the first of them is more than 3 days old, and the community’s admins are mailed. Any 2xx resets the count.
| Attempt | When |
|---|---|
| 1 | Sent at once |
| 2 | 1 minute after the attempt before |
| 3 | 5 minutes after the attempt before |
| 4 | 30 minutes after the attempt before |
| 5 | 2 hours after the attempt before |
| 6 | 6 hours after the attempt before |
| 7 | 12 hours after the attempt before |
| 8 | 24 hours after the attempt before |
| 9 | 24 hours after the attempt before |
Verifying signatures
The secret for an endpoint starts with whsec_ and is shown once, when the endpoint is added. The AR-Signature header holds a timestamp and one or more signatures: t=<unix seconds>,v1=<hex>. Each v1 is the hex HMAC-SHA256, keyed with the secret, of the timestamp, a dot, and the raw body. Rolling a secret sends two v1 values for 24 hours, so accept either one.
- Read the raw body before parsing it.
- Reject the request if the timestamp is more than 300 seconds from your clock.
- Compute the HMAC and compare it with every
v1in constant time. - Parse the body, then dedupe on its
id.
const crypto = require('node:crypto');
// rawBody: the request body exactly as received, before any JSON parsing.
// header: the AR-Signature header. secret: the whsec_ value for this endpoint.
function verifyArSignature(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = String(header || '').split(',').map((p) => p.trim());
const t = Number((parts.find((p) => p.startsWith('t=')) || '').slice(2));
const sigs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!Number.isInteger(t) || t <= 0 || sigs.length === 0) return false;
if (Math.abs(nowSeconds - t) > 300) return false; // too old or too new
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest();
let ok = false;
for (const s of sigs) {
const given = Buffer.from(s, 'hex');
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) ok = true;
}
return ok;
}
module.exports = { verifyArSignature };
Test vectors
Each signature below was made with openssl dgst -sha256 -hmac over t + "." + body. A correct verifier accepts each one with its own timestamp as the current time.
[
{
"secret": "whsec_test_secret_1",
"t": 1793642400,
"body": "{\"id\":\"evt_0001\",\"type\":\"ping\"}",
"signature": "1d259a7ecdc4863822b1cbb5989862afca69eae1f11266c56c2343d5c7637f69"
},
{
"secret": "whsec_test_secret_2",
"t": 1793642400,
"body": "{\"id\":\"evt_0001\",\"type\":\"ping\"}",
"signature": "d370ab9a1a8dd991b16b335c1e930c34542bc2a560f7904aca16ee38492635e9"
},
{
"secret": "whsec_test_secret_1",
"t": 1793642400,
"body": "{\"note\":\"café ✓\"}",
"signature": "9cec184fc28de6cc06b7395fad06ff73f6f2c0141f7a34019122bef60e67320f"
}
]Privacy and data
An API key lets a tool your community connects read your community’s bookings and amenities. With the scopes your community grants, it can also read the member list and approve, decline or cancel bookings. By default it sees as little as the task needs, and some things are never sent.
- With
bookings:readalone, a booking shows its member as an id and a name such as “Dana K.”, a first name and a last initial. - With
members:readas well, the member’s full name, email address, street address, role and standing can be read, and the member list opens. The key dialog says this before the owner ticks the scope. - Phone numbers and household notes are never sent.
- Of a signed agreement, only the way it was signed and the time are sent. The typed name, initials, IP address and browser details are not.
- Payment amounts, Stripe ids and deposit details are never sent. A booking shows only a short payment status.
- Attendant email addresses and the text of the rules are never sent.
- The answers on a booking request (purpose, alcohol, outside vendors) are sent, because they are the request.
- The notes an admin writes on a decision or a cancellation are sent only to a key with
bookings:write. - If a member’s data has been erased, the booking shows no member and
resident_erasedis true. - Webhooks carry the minimal member only, and cannot be expanded. Each is built at the time it is sent, so it reflects the booking and any erasure as they are then.
Your community chooses where a key’s data goes. If your community connects another tool with an API key, we send that tool the details its scopes open, including member details if your community grants members:read, on your community’s instruction. That tool is your community’s choice, and its own privacy terms apply. Your community is responsible for the keys it makes and for where it sends member data, under the Amenity Reserve API terms. Better Seasons staff can revoke a key that is leaked or abused. The API and its limits can change, with at least 30 days’ notice of a change that removes a field or an endpoint, except a change needed for security.