Skip to content

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.

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.

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 (not tentative, tentative_expired, expired, or canceled)
  • The booking contract has not started (its checkIn is 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.

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.

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 Pending to Approved
  • The booking moves from confirmed to canceled (skipped if the booking is already canceled, so re-approving is idempotent)
  • The booking’s cancellationReason is 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.

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.

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, cancelationRequests returns 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 = PENDING because at least one of its requests is pending. Read cancelationRequests(status: [PENDING]) for the current open request, or cancelationRequests (unfiltered) for the full history.

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

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 already canceled, the request still transitions to Approved but the booking is not canceled again. No duplicate booking-cancelation webhook event is sent.

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.

Cancelation requests sit alongside bookings in the inventory hierarchy:

  • A booking can have many cancelation requests over time, but at most one in the Pending state
  • 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.