# 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

### Lifecycle

Every cancelation request follows the same four-state lifecycle:

```text
       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

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.

### 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

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.

## GraphQL API

### Types

#### `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).

#### Enums

**`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

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.

```graphql
query BookingPendingCancelationRequest($id: ID!) {
  bookingAvailability {
    booking(id: $id) {
      id
      cancelationRequests(status: [PENDING]) {
        nodes {
          __typename
          status
          requestedAt
          cancelationReason
        }
      }
    }
  }
}
```

```json
{
  "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

`Booking.cancelationRequests(status: [...])` returns the full list of requests for the booking, newest first. It follows the standard [pagination pattern](/concepts/api-paradigms/connections/) 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.

```graphql
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
        }
      }
    }
  }
}
```

```json
{
  "id": "booking-id"
}
```

To filter to a single state, supply `status`:

```graphql
query BookingApprovedCancelationRequests($id: ID!, $status: [ApprovalRequestStatusEnum!]) {
  bookingAvailability {
    booking(id: $id) {
      cancelationRequests(status: $status) {
        nodes {
          __typename
          ... on ApprovedCancelationRequest {
            approvedAt
          }
        }
      }
    }
  }
}
```

```json
{
  "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

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.

```graphql
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
      }
    }
  }
}
```

```json
{
  "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:

```json
{
  "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](/concepts/api-paradigms/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.

### 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.

```graphql
mutation SubmitCancelationRequest($input: SubmitCancelationRequestInput!) {
  bookingAvailability {
    bookings {
      submitCancelationRequest(submitCancelationRequest: $input) {
        booking {
          id
          status
          cancelationRequests(status: [PENDING]) {
            nodes {
              __typename
              status
              requestedAt
              cancelationReason
            }
          }
        }
      }
    }
  }
}
```

```json
{
  "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](#eligibility)) |
| `cancelation_request_already_pending` | A pending request for this booking already exists |

### 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.

```graphql
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:

```json
{
  "input": {
    "bookingId": "booking-id",
    "transition": "APPROVE"
  }
}
```

Decline:

```json
{
  "input": {
    "bookingId": "booking-id",
    "transition": "DECLINE"
  }
}
```

Withdraw:

```json
{
  "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.

## 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:

```json
{
  "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](/concepts/webhooks/) for the general webhook delivery model and signature verification.

## 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 `Pending` state
- A cancelation request always belongs to exactly one **workspace** (the booking's workspace) and exactly one **booking**

See [Structure & Terms](/concepts/structure-and-terms/) for more on the booking model.
