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

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

`PROPERTY_TRANSFER`, `CAMPUS_CHANGE`, `LOCATION`, `ACADEMIC`, `FINANCIAL`, `MEDICAL`, `NO_PLACE`, `NO_VISA`, `NO_SHOW`, `COMPLAINT`, `COMPASSIONATE`, `PROPERTY_SOLD`, `PROCESSING_ERROR`, `OTHER`.

### Reading the pending request on a booking

`Booking.pendingCancelationRequest` returns the (at most one) pending request for the booking, or `null` when none exists. Use this when you only care about the current open request.

```graphql
query BookingPendingCancelationRequest($id: ID!) {
  bookingAvailability {
    booking(id: $id) {
      id
      pendingCancelationRequest {
        __typename
        status
        requestedAt
        cancelationReason
      }
    }
  }
}
```

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

`__typename` will be `"PendingCancelationRequest"` when a pending request exists, and the field itself will be `null` 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 [Connections](/concepts/api-paradigms/connections.md) 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, both `pendingCancelationRequest` and `cancelationRequests` return as if no requests exist, regardless of any rows that may have been written before the flag was toggled.

### 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
          pendingCancelationRequest {
            __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.md) 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.md) for more on the booking model.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.lavanda.app/bookings/cancelation-requests.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
