# Cancel a hotel booking

> Cancel a confirmed hotel booking owned by your agency. Requires Read+Write permission.

Endpoint: `POST /api/v1/hotels/cancel_hotel`

Source: https://fx-port.com/docs/hotels/cancel

Cancel a **confirmed** hotel booking of your agency. FX-Port cancels it at the supplier and gives back what the cancellation schedule of the rate allows: everything before the free cancellation deadline, less (or nothing) after it.

**Permission:** Read+Write (a read key answers `403 read_write_permission_required`)


> [!WARNING]
> **Only a confirmed booking can be cancelled**
>
> Right after [`book_hotel`](https://fx-port.com/docs/hotels/book.md) the booking is `pending`: wait until [the booking](https://fx-port.com/docs/hotels/get-booking.md) is `confirmed`. A booking that is `pending`, `failed`, or already `cancelled` or `refunded` answers `400 invalid_status`.

> [!TIP]
> **In the sandbox, cancel every booking you make**
>
> Every sandbox booking is a real order on the supplier's test contract. The test hotels cancel **for free until 21:00 UTC on the check-in day** (`test_hotel` until 00:00 UTC on that day). See [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md).


## Request body

`POST /api/v1/hotels/cancel_hotel`

| Field | Type | Required | Description |
|---|---|---|---|
| `booking_id` | string | Yes | The `bookingId` of [`book_hotel`](https://fx-port.com/docs/hotels/book.md). |


**Request**

```bash
curl https://api.fx-port.com/api/v1/hotels/cancel_hotel \
  -H "Authorization: Bearer fxp_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "booking_id": "HMVXX4ZQ3"
}'
```


### Legacy names

These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)).

| Older name | Use instead |
|---|---|
| `partner_order_id`, `partnerOrderId`, `bookingId` | `booking_id` |

## Response

A real answer of the sandbox, in 3.5 to 4 seconds (1 to 4 seconds is usual):

~~~json
{
  "environment": "sandbox",
  "success": true,
  "status": "ok",
  "bookingId": "HMVXX4ZQ3",
  "bookingStatus": "refunded",
  "adminCancel": false,
  "refund": {
    "cancellationType": "free",
    "type": "Free cancellation",
    "agencyAmount": 1482,
    "travelerAmount": 1484.97,
    "currency": "DZD",
    "providerAmount": 3,
    "providerCurrency": "EUR"
  },
  "refundProcessed": true,
  "webhookSent": true,
  "processingTime": 3.488
}
~~~

| Field | Description |
|---|---|
| `bookingStatus` | The new status of the booking: `refunded` (free cancellation), `partial_refund` (a penalty applied) or `cancelled` (nothing refunded). Also as `booking_status`. |
| `refund.cancellationType` | `free` (the supplier refunded everything), `partial` (a penalty applied) or `none` (no refund). Also as `cancellation_type`. |
| `refund.type` | The same as a label: `Free cancellation`, `Partial cancellation` or `No refund`. |
| `refund.agencyAmount` | **What is credited back to your agency**, in your currency. For a free cancellation it is everything you were debited (`totalPrice - agencyCommission`). Also as `agency_refund_amount`. |
| `refund.travelerAmount` | What the traveler gets back, in your currency. For a free cancellation it is the whole `totalPrice`. Also as `traveler_refund_amount`. |
| `refund.currency` | Your agency currency. Also as `agency_currency`. |
| `refund.providerAmount`, `providerCurrency` | What the supplier refunded, in its own currency (EUR). For information. |
| `refundProcessed` | `true` when the refund was handled. In the sandbox nothing moves and it is still `true`. Also as `refund_processed`. |
| `adminCancel` | `true` when FX-Port staff cancelled the booking. Always `false` for your call. |
| `webhookSent` | An internal step of FX-Port, not a message to you: **FX-Port sends no webhooks to customers.** |
| `processingTime` | Seconds the call took. |

The keys are camelCase. Twins in snake_case sit next to them: `booking_id`, `booking_status`, `refund_processed` and `processing_time` at the top, and `cancellation_type`, `agency_refund_amount`, `traveler_refund_amount`, `supplier_refund_amount`, `agency_currency` and `supplier_currency` inside `refund`. See [Legacy names](https://fx-port.com/docs/legacy-names.md).

## What you get back

| When you cancel | `bookingStatus` | `cancellationType` | Your refund |
|---|---|---|---|
| Before the free cancellation deadline | `refunded` | `free` | Everything you were debited. |
| After it, with a penalty | `partial_refund` | `partial` | What was debited minus the penalty of the rate. |
| After it, on a non-refundable rate | `cancelled` | `none` | Nothing. |

When the deadline is, and what each window costs, is in the `cancellationPolicy` of the rate (`freeCancellationBefore` and `policies[]`): see [Get hotel rates](https://fx-port.com/docs/hotels/rates.md#cancellation-policy). Cancelling after the free window still succeeds; it is the refund that is smaller. We could not exercise the penalty windows in the sandbox, because the test hotels refund in full until the check-in day: the lines above for `partial` and `none` come from the refund rules of the code, not from a sandbox run.

## If the call times out

The supplier sometimes takes about 30 seconds to answer a cancellation. The call then fails with **`503 supplier_timeout`**, and the cancellation **may or may not have happened**. So:

1. Read the booking: [`GET /bookings/{booking_id}?product=hotels`](https://fx-port.com/docs/hotels/get-booking.md).
2. `refunded`, `partial_refund` or `cancelled`: it went through.
3. Still `confirmed`: call `cancel_hotel` again. In our tests the second call answered in under a second.

~~~python
for attempt in range(3):
    try:
        response = requests.post(f"{API}/hotels/cancel_hotel", headers=HEADERS, json={"booking_id": booking_id}, timeout=60)
    except requests.Timeout:
        continue  # read the booking if you can; it stays confirmed until the supplier answers
    if response.ok:
        break
    time.sleep(3)  # a 5xx once in a while: the booking is still confirmed, ask again
~~~

## Errors

| HTTP | Code | Cause |
|---|---|---|
| `400` | `invalid_status` | The booking is not `confirmed`. |
| `400` | `missing_field`, `invalid_booking_id` | `booking_id` is missing (`errors[0].field` is `booking_id`), or is not 1 to 256 characters of letters, digits and `_ . : -`. |
| `400` | the supplier's code | The supplier refused the cancellation (`errors[0].type` is `supplier_error`). The booking is unchanged. |
| `403` | `read_write_permission_required`, `forbidden` | A read key, or a booking of another agency. |
| `404` | `booking_not_found` | No booking of your agency has this id in this environment. |
| `502` | `supplier_invalid_response` | The supplier answered in a form that could not be used. The cancellation may or may not have happened: read the booking, and call again only if it is still `confirmed`. |
| `503` | `supplier_timeout` | The supplier did not answer in time. Read the booking, then retry. |

## Related


- [Get a hotel booking](https://fx-port.com/docs/hotels/get-booking.md): The status after a cancellation, and what was refunded.
- [Get hotel rates](https://fx-port.com/docs/hotels/rates.md): The cancellation schedule of a rate.
- [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md): What you were debited, and what the traveler paid.
- [API reference: Cancel a confirmed hotel booking](https://fx-port.com/docs/api/cancel-hotel.md): Every field and error.
