Cancelation Requests
A cancelation request is an explicit, reviewable record that a confirmed booking should be canceled. Instead of canceling the booking immediately, a caller with the right permission opens a request against the booking; another caller (or the same one, if they hold both permissions) then approves, declines, or withdraws it. Approving cancels the booking in the same transaction; declining and withdrawing leave the booking confirmed.
Cancelation requests are a per-workspace opt-in. When the workspace flag is off, all the operations described on this page short-circuit with cancelation_approvals_disabled, and the federated read fields return null / an empty list.
Key Concepts
Section titled “Key Concepts”Lifecycle
Section titled “Lifecycle”Every cancelation request follows the same four-state lifecycle:
submit approve[None] -------> [Pending] ---------> [Approved] (booking is now canceled) | |--- decline ---> [Declined] (booking stays confirmed) | |--- withdraw --> [Withdrawn] (booking stays confirmed)There is at most one pending request per booking at any time. Once a request is in a terminal state (approved, declined, or withdrawn), a new pending request can be opened.
Eligibility
Section titled “Eligibility”A booking is eligible for a cancelation request when all of the following hold:
- The workspace flag for cancelation requests is enabled
- The booking is a Product Booking (it has a
productId) - The booking status is
confirmed(nottentative,tentative_expired,expired, orcanceled) - The booking contract has not started (its
checkInis strictly in the future) - The booking is outside its free-cancelation (cool-off) period
If any of these is false at submit time, submitCancelationRequest returns booking_not_eligible_for_cancelation_approval. Eligibility is only checked on submit; transitions on an already-pending request are not re-gated by eligibility.
Permissions
Section titled “Permissions”Authorization is checked per operation, against the booking resource:
| Operation | Required permission on the booking |
|---|---|
| Submit a cancelation request | request_cancelation |
| Approve a pending request | approve_cancelation |
| Decline a pending request | decline_cancelation |
| Withdraw a pending request | withdraw_cancelation |
A caller who lacks the matching permission gets back unauthorized, regardless of the state of the booking or any pending request.
Side effects of approval
Section titled “Side effects of approval”When a pending request is approved, the cancelation is applied to the booking inside the same database transaction as the request status flip. Concretely, on APPROVE:
- The request moves from
PendingtoApproved - The booking moves from
confirmedtocanceled(skipped if the booking is already canceled, so re-approving is idempotent) - The booking’s
cancellationReasonis set to the reason that was attached to the original request (if one was supplied) - A webhook event for the request state change fires, followed by the existing booking-cancelation event
If the booking is already canceled when the approval lands (for example, because someone canceled it through the direct cancelation API in the meantime), the request still moves to Approved, but the booking-cancelation side effect and its webhook event are suppressed. There is no second booking cancelation.
GraphQL API
Section titled “GraphQL API”CancelationRequest (interface)
Section titled “CancelationRequest (interface)”The polymorphic interface every request implements. Reads return this interface; clients use inline fragments to pull state-specific timestamps.
| Field | Type | Description |
|---|---|---|
status |
ApprovalRequestStatusEnum! |
Current lifecycle state of the request |
requestedAt |
ISO8601DateTime! |
When the request was first submitted |
cancelationReason |
BookingCancelationReasonEnum |
Optional reason the caller attached at submit time |
The owning workspace is reachable via the booking itself (booking.workspace { id }); a cancelation request always belongs to the same workspace as its booking.
The interface has four concrete implementations, one per lifecycle state. Each adds a single state-specific timestamp:
| Concrete type | Extra field |
|---|---|
PendingCancelationRequest |
(interface fields only) |
ApprovedCancelationRequest |
approvedAt: ISO8601DateTime! |
DeclinedCancelationRequest |
declinedAt: ISO8601DateTime! |
WithdrawnCancelationRequest |
withdrawnAt: ISO8601DateTime! |
Decision timestamps are state-specific by design: a PendingCancelationRequest will not carry an approvedAt, and an ApprovedCancelationRequest will not carry a declinedAt. Use inline fragments to pull whichever timestamp matches the request’s current state (see the query examples below).
ApprovalRequestStatusEnum - lifecycle state of a cancelation request:
| Value | Description |
|---|---|
PENDING |
The request has been submitted and is awaiting a decision |
APPROVED |
The request has been approved |
DECLINED |
The request has been declined |
WITHDRAWN |
The request was withdrawn before a decision was made |
ApprovalRequestTransitionEnum - the event applied to a pending request:
| Value | Description |
|---|---|
APPROVE |
Approve the pending request (also cancels the booking) |
DECLINE |
Decline the pending request |
WITHDRAW |
Withdraw the pending request |
BookingCancelationReasonEnum - the reason a caller may attach when submitting a request. Values are:
ACADEMIC, CAMPUS_CHANGE, COMPASSIONATE, COMPLAINT, FINANCIAL, LOCATION, MEDICAL, NO_PLACE,
NO_SHOW, NO_VISA, OTHER, PROCESSING_ERROR, PROPERTY_SOLD, PROPERTY_TRANSFER.
Reading the pending request on a booking
Section titled “Reading the pending request on a booking”Read the pending request from cancelationRequests(status: [PENDING]). Because a booking can have at most one
pending cancelation request at a time (enforced by a partial unique index), filtering the connection to PENDING
and reading the first node gives you the current open request, or no node when none exists.
query BookingPendingCancelationRequest($id: ID!) { bookingAvailability { booking(id: $id) { id cancelationRequests(status: [PENDING]) { nodes { __typename status requestedAt cancelationReason } } } }}{ "id": "booking-id"}nodes will contain a single PendingCancelationRequest when a pending request exists, and be empty when one does not.
Reading the full request history on a booking
Section titled “Reading the full request history on a booking”Booking.cancelationRequests(status: [...]) returns the full list of requests for the booking, newest first. It follows the standard pagination pattern used across the API. Pass status: to narrow the result to one or more lifecycle states; omit it to get everything.
Because the connection’s nodes are typed as the CancelationRequest interface, you use inline fragments to pull the state-specific decision timestamp.
query BookingCancelationRequests($id: ID!) { bookingAvailability { booking(id: $id) { id cancelationRequests { totalCount nodes { __typename status requestedAt cancelationReason ... on ApprovedCancelationRequest { approvedAt } ... on DeclinedCancelationRequest { declinedAt } ... on WithdrawnCancelationRequest { withdrawnAt } } pageInfo { hasNextPage endCursor } } } }}{ "id": "booking-id"}To filter to a single state, supply status:
query BookingApprovedCancelationRequests($id: ID!, $status: [ApprovalRequestStatusEnum!]) { bookingAvailability { booking(id: $id) { cancelationRequests(status: $status) { nodes { __typename ... on ApprovedCancelationRequest { approvedAt } } } } }}{ "id": "booking-id", "status": ["APPROVED"]}Note: When the workspace flag for cancelation requests is off,
cancelationRequestsreturns as if no requests exist, regardless of any rows that may have been written before the flag was toggled.
Listing bookings by cancelation request state
Section titled “Listing bookings by cancelation request state”To find every booking with a pending cancelation request (or one in any other lifecycle state) without having to scan every booking individually, filter the top-level bookings.bookings query by cancelationRequests.status. A booking matches when at least one of its cancelation requests is in the given state. Because a booking can have at most one pending cancelation request at a time (enforced by a partial unique index), filtering on PENDING returns each matching booking exactly once, so the common case of listing every booking with an open pending request is a single query.
query BookingsWithPendingCancelationRequests($filters: BookingsFiltersInput, $first: Int) { bookings { bookings(filters: $filters, first: $first) { totalCount nodes { id status cancelationRequests(status: [PENDING]) { nodes { __typename requestedAt cancelationReason } } } pageInfo { hasNextPage endCursor } } }}{ "filters": { "cancelationRequests": { "status": { "eq": "PENDING" } } }, "first": 50}The status filter accepts the same four values as ApprovalRequestStatusEnum on the read side: PENDING, APPROVED, DECLINED, WITHDRAWN. Pass in: with a list to match more than one state at a time:
{ "filters": { "cancelationRequests": { "status": { "in": ["PENDING", "APPROVED"] } } }}Filter shape:
| Field | Type | Description |
|---|---|---|
cancelationRequests.status.eq |
ApprovalRequestStatus |
Match bookings whose cancelation requests contain at least one in this state |
cancelationRequests.status.in |
[ApprovalRequestStatus!] |
Match bookings whose cancelation requests contain at least one in any of these states |
Combine with the standard pagination arguments (first, after, etc.) and other filters on BookingsFiltersInput (for example status, checkIn, building) to scope the result set. See Connections for the pagination model.
Note: A booking with a mix of states (for example, an old withdrawn request and a new pending one) is returned by
cancelationRequests.status.eq = PENDINGbecause at least one of its requests is pending. ReadcancelationRequests(status: [PENDING])for the current open request, orcancelationRequests(unfiltered) for the full history.
Submitting a cancelation request
Section titled “Submitting a cancelation request”Use the submitCancelationRequest mutation under the bookings namespace. The request is keyed by bookingId; the partial unique index guarantees at most one pending request per booking, so you do not (and cannot) supply a request id.
mutation SubmitCancelationRequest($input: SubmitCancelationRequestInput!) { bookingAvailability { bookings { submitCancelationRequest(submitCancelationRequest: $input) { booking { id status cancelationRequests(status: [PENDING]) { nodes { __typename status requestedAt cancelationReason } } } } } }}{ "input": { "bookingId": "booking-id", "cancelationReason": "NO_VISA" }}Input fields:
| Field | Type | Description |
|---|---|---|
bookingId |
ID! |
The booking to open a cancelation request against |
cancelationReason |
BookingCancelationReasonEnum |
Optional reason, surfaced on both the request and the resulting cancelation if the request is later approved |
Error codes:
| Code | When |
|---|---|
cancelation_approvals_disabled |
The workspace flag is off |
booking_not_found |
No booking matches bookingId |
unauthorized |
The caller lacks request_cancelation on the booking |
booking_not_eligible_for_cancelation_approval |
The booking fails one of the eligibility checks (see Eligibility) |
cancelation_request_already_pending |
A pending request for this booking already exists |
Transitioning a pending request
Section titled “Transitioning a pending request”Use the transitionCancelationRequest mutation to approve, decline, or withdraw the pending request on a booking. The request is again keyed by bookingId, since at most one pending request can exist per booking.
mutation TransitionCancelationRequest($input: TransitionCancelationRequestInput!) { bookingAvailability { bookings { transitionCancelationRequest(transitionCancelationRequest: $input) { booking { id status cancellationReason cancelationRequests(status: [APPROVED, DECLINED, WITHDRAWN]) { nodes { __typename ... on ApprovedCancelationRequest { approvedAt } ... on DeclinedCancelationRequest { declinedAt } ... on WithdrawnCancelationRequest { withdrawnAt } } } } } } }}Approve:
{ "input": { "bookingId": "booking-id", "transition": "APPROVE" }}Decline:
{ "input": { "bookingId": "booking-id", "transition": "DECLINE" }}Withdraw:
{ "input": { "bookingId": "booking-id", "transition": "WITHDRAW" }}Input fields:
| Field | Type | Description |
|---|---|---|
bookingId |
ID! |
The booking whose pending request is being transitioned |
transition |
ApprovalRequestTransitionEnum! |
One of APPROVE, DECLINE, WITHDRAW |
Error codes:
| Code | When |
|---|---|
cancelation_approvals_disabled |
The workspace flag is off |
booking_not_found |
No booking matches bookingId |
unauthorized |
The caller lacks the per-transition permission (approve_cancelation, decline_cancelation, or withdraw_cancelation) on the booking |
cancelation_request_not_pending |
No pending request exists for this booking (it may have been transitioned already, or it may never have been opened) |
Note: On
APPROVE, if the booking is alreadycanceled, the request still transitions toApprovedbut the booking is not canceled again. No duplicate booking-cancelation webhook event is sent.
Webhooks
Section titled “Webhooks”Each lifecycle change emits a webhook event. On approval, two events fire in order: first the approved event for the request, then the existing booking-cancelation event for the booking. Declining and withdrawing emit only their own event; the booking stays confirmed.
| Event type | When it fires |
|---|---|
v1.booking.cancelation_request.requested |
A new pending request is opened via submitCancelationRequest |
v1.booking.cancelation_request.approved |
A pending request is approved via transitionCancelationRequest (followed by v1.booking.canceled, unless the booking was already canceled) |
v1.booking.cancelation_request.declined |
A pending request is declined via transitionCancelationRequest |
v1.booking.cancelation_request.withdrawn |
A pending request is withdrawn via transitionCancelationRequest |
All four events share the same payload shape:
{ "type": "v1.booking.cancelation_request.approved", "id": "evt_2x...", "timestamp": "2026-05-22T13:00:00Z", "booking": { "id": "booking-id" }, "workspace": { "id": "workspace-id" }}Payload fields:
| Field | Type | Description |
|---|---|---|
type |
string |
The event type (one of the four values above) |
id |
string |
Stable, unique identifier for this event delivery |
timestamp |
string (ISO 8601) |
When the underlying lifecycle change happened |
booking.id |
string |
The booking the request targets |
workspace.id |
string |
The workspace that owns the booking |
To resolve any further detail about the request or the booking after receiving an event, re-query the booking via the GraphQL API using the booking.id from the payload. The federated reads documented above are the canonical source of truth.
See Webhooks for the general webhook delivery model and signature verification.
Relationships
Section titled “Relationships”Cancelation requests sit alongside bookings in the inventory hierarchy:
- A booking can have many cancelation requests over time, but at most one in the
Pendingstate - A cancelation request always belongs to exactly one workspace (the booking’s workspace) and exactly one booking
See Structure & Terms for more on the booking model.