# Create reservation

`POST /v2/bob-box/reservations`

**Base URLs:**

- Production: `https://api.bobbox.co.za`

**Authentication:** Bearer token required (see the Authentication page).

## Example request

```bash
curl -X POST 'https://api.bobbox.co.za/v2/bob-box/reservations' \
  -H "content-type: application/json" \
  -H "Authorization: Bearer <API_KEY>" \
  -d '{
    "location_id": 213,
    "reservation_type": "client-pickup",
    "parcel_length": 30,
    "parcel_width": 20,
    "parcel_height": 10,
    "allow_auto_upgrade": true,
    "contact_name": "Jane Customer",
    "contact_number": "+27833771167",
    "reference": "ORDER-1452",
    "custom_data": {
        "shipment_id": 456
    }
}'
```

Reserve a compartment at a location for a parcel. The response carries the compartment and all three PINs.

Compartment matching and `allow_auto_upgrade` are documented once, below, under [How the parcel is sized](#how-the-parcel-is-sized). Read that before you build.

### Request

**Method:** POST
**URL:** `https://api.bobbox.co.za/v2/bob-box/reservations`

#### Request body parameters

| Key | Description | Type | Required |
| --- | --- | --- | --- |
| location_id | The location to reserve a compartment at. | integer | Yes |
| reservation_type | `client-dropoff`, `client-pickup`, or `client-dropoff-and-pickup`. | string | **Yes** |
| parcel_length | Parcel length in cm. | float | See sizing below |
| parcel_width | Parcel width in cm. | float | See sizing below |
| parcel_height | Parcel height in cm. | float | See sizing below |
| volumetric_weight | Parcel volumetric weight in kg — `length × width × height ÷ 4000`, sides in cm. Used for sizing only when no dimensions are sent. | float | See sizing below |
| allow_auto_upgrade | Use a larger compartment when none of the matched size is free. **Defaults to `false`.** See [How the parcel is sized](#how-the-parcel-is-sized). | boolean | No |
| reference | Your own reference for this reservation (e.g. an order number). Max 255 bytes — accented and non-Latin letters use more than one byte each. | string | No |
| contact_name | Name of the person collecting or dropping off. Max 255 bytes — accented and non-Latin letters use more than one byte each. | string | No |
| contact_number | Contact number for the optional Bob Box collection SMS. Must be a valid **South African** number — see the note below. | string | No |
| parcel_count | How many parcels go in the compartment. Must not be negative. | integer | No |
| custom_data | Arbitrary JSON object for your own metadata. Echoed back on every read of this reservation and on every webhook. Max 4096 bytes when serialized. | object | No |
| bob_box_location_id | **Deprecated** — alias for `location_id`, still accepted. Use `location_id`. | integer | No |
| custom_reference | **Deprecated** — alias for `reference`, still accepted. Use `reference`. | string | No |

`location_id` and `bob_box_location_id` are interchangeable; send one of them. Same for `reference` and `custom_reference`. When both are sent, the new name wins.

#### Field limits

Requests that exceed these limits will be rejected with a `400`.

| Field | Limit |
| --- | --- |
| reference | 255 characters |
| contact_name | 255 characters |
| custom_data | 4 KB (4096 bytes) serialized |

The character limits are counted in bytes, so accented and non-Latin letters use more than one each. The limit on `reference` applies to `custom_reference` too.

#### How the parcel is sized

This is the single reference for compartment matching. Other pages link here rather than repeat it.

Bob Box works in two steps:

1. **Match a size.** The parcel is matched to the **smallest** size it fits — `Small`, then `Medium`, then `Large`. Both sets of sides are sorted before they are compared, so orientation does not matter, and neither does the order you send the dimensions in.
2. **Claim a compartment.** The first free compartment of the matched size is claimed.

##### Compartment sizes

Every compartment is the same width and length. Only the height changes.

| Size | Width (cm) | Length (cm) | Height (cm) | Max volumetric weight (kg) |
| --- | --- | --- | --- | --- |
| `Small` | 32 | 56 | 10 | 4.48 |
| `Medium` | 32 | 56 | 24 | 10.752 |
| `Large` | 32 | 56 | 52 | 23.296 |

**Volumetric factor: 4000.** Volumetric weight in kg is `length × width × height ÷ 4000`, with the sides in centimetres. Each `max_volumetric_weight` above is just that compartment's own volume put through the same formula, so a parcel that fits a size by its sides also fits it by volumetric weight.

**`allow_auto_upgrade` decides what happens when step 2 finds nothing.** It defaults to `false`, so the reservation fails when the matched size is full — even when bigger compartments are standing empty. Send `true` and Bob Box falls back to any larger size instead.

**Send `allow_auto_upgrade: true` unless you have a reason not to.** It is also the only setting under which `compartment_availability` predicts this call exactly — see "Checking space before you reserve" in the Workflows section.

##### What to send

No single sizing field is required, but always send one of these three sets:

- **The three dimensions** — the normal case.
- **`volumetric_weight` with no dimensions** — each compartment is checked against its own `max_volumetric_weight` instead of against the parcel's sides.

  **Watch out:** step 1 still reads the dimensions, and with none sent it always lands on `Small`. With the default `allow_auto_upgrade: false` you can only ever get a Small compartment, however heavy the parcel is. Sizing by weight needs `allow_auto_upgrade: true`.
- **Nothing at all** — the request still succeeds and takes the smallest compartment. Only do this when you know the parcel is small.

A parcel larger than the biggest compartment is rejected with `400 Parcel dimensions exceed maximum compartment size` before any compartment is checked.

#### Contact number format

The number is parsed as a South African number and must be valid for that region. Both `+27833771167` and `0833771167` are accepted, and the value is stored in E.164 form. A valid number from another country is **rejected**.

#### Reference uniqueness

A `reference` must be unique among *active* reservations at the same location. Reusing one that is still tied to an active reservation there returns `400 There is already an active reservation with this reference at this location.` The same reference is fine at a different location, or once the earlier reservation has finished.

If you send no `reference` but your `custom_data` contains a string field named `shipment_reference`, that value is used as the reservation's reference. That fallback is not length-checked and not uniqueness-checked — send `reference` directly if you need either.

### Response

**Status code:** 200 OK
**Content-Type:** application/json

**All three PINs are returned here, immediately.** They are read off the compartment as it is claimed — you do not need to poll or call the PINs endpoint to get them.

**Response body example:**

```json
{
    "id": 8931,
    "status": "waiting-for-dropoff",
    "type": "client-dropoff",
    "bob_box_location": {
        "id": 213,
        "name": "sandton_city",
        "human_name": "Sandton City",
        "type": "bob-box-locker",
        "description": "Ground floor, near the west entrance.",
        "structured_address": {
            "entered_address": "83 Rivonia Rd, Sandhurst, Sandton, 2196",
            "company": null,
            "street_address": "83 Rivonia Rd",
            "local_area": "Sandhurst",
            "city": "Sandton",
            "zone": "Gauteng",
            "code": "2196",
            "country": "ZA",
            "lat": -26.107567,
            "lng": 28.05304
        },
        "address": "83 Rivonia Rd, Sandhurst, Sandton, 2196",
        "structured_trading_hours": {
            "monday": { "start_time": "09:00", "end_time": "21:00" },
            "tuesday": { "start_time": "09:00", "end_time": "21:00" },
            "wednesday": { "start_time": "09:00", "end_time": "21:00" },
            "thursday": { "start_time": "09:00", "end_time": "21:00" },
            "friday": { "start_time": "09:00", "end_time": "21:00" },
            "saturday": { "start_time": "09:00", "end_time": "18:00" },
            "sunday": { "start_time": "09:00", "end_time": "18:00" },
            "holidays": { "start_time": "", "end_time": "" }
        },
        "trading_hours": "Mon-Fri 09:00-21:00, Sat-Sun 09:00-18:00",
        "image_urls": ["https://images.bob.co.za/locations/213/main.jpg"],
        "status": "active",
        "compartment_errors": null
    },
    "locker_id": 44,
    "compartment": {
        "number": 12,
        "width": 32,
        "length": 56,
        "height": 10,
        "max_volumetric_weight": 4.48,
        "type": "Small"
    },
    "contact_name": "Jane Customer",
    "contact_number": "+27833771167",
    "reference": "ORDER-1452",
    "custom_reference": "ORDER-1452",
    "dropoff_pin": "482150",
    "pickup_pin": "903417",
    "return_pin": "118736",
    "flat_fee_end_time": "2026-08-27T11:40:00+02:00",
    "intervention_time": "2026-08-29T11:40:00+02:00",
    "time_dropped_off": null,
    "time_completed": null,
    "time_created": "2026-08-25T11:40:00+02:00",
    "time_modified": "2026-08-25T11:40:00+02:00"
}
```

#### Response fields

| Key | Description | Type |
| --- | --- | --- |
| id | Unique identifier for the reservation. | integer |
| status | `waiting-for-dropoff`, `waiting-for-pickup`, `expired`, `cancelled`, `returned`, `complete`, or `cleared`. | string |
| type | The reservation type you requested, echoed back. | string |
| bob_box_location | The location the compartment was reserved at. See the location fields below — this is a **narrower** object than the one `GET /account/locations` returns. | object |
| locker_id | ID of the physical locker unit at the location. | integer |
| compartment | The compartment claimed for this reservation. | object |
| contact_name | The contact name, if you sent one. | string |
| contact_number | The contact number in E.164 form, if you sent one. | string |
| reference | Your reference for this reservation. | string |
| custom_reference | **Deprecated** — the same value as `reference`. Read `reference`. | string |
| custom_data | Your metadata object, if you sent one. | object |
| dropoff_pin | PIN for the person placing the parcel in the locker. See [Using the PINs](#using-the-pins). | string |
| pickup_pin | PIN for the person collecting the parcel. See [Using the PINs](#using-the-pins). | string |
| return_pin | PIN for removing a parcel that was never collected. See [Using the PINs](#using-the-pins). | string |
| flat_fee_end_time | When the flat-fee window ends and overstay charges begin. Derived from your account's flat-fee duration. | string (datetime) |
| intervention_time | When the reservation becomes eligible for manual intervention (clearing the compartment). Sits after `flat_fee_end_time`. | string (datetime) |
| time_dropped_off | When the parcel was dropped off. `null` until it happens. | string (datetime) |
| time_completed | When the parcel was collected. `null` until it happens. | string (datetime) |
| time_created | When the reservation was created. | string (datetime) |
| time_modified | When the reservation was last modified. | string (datetime) |

**Compartment fields:**

| Key | Description | Type |
| --- | --- | --- |
| number | Compartment number within the locker. | integer |
| width / length / height | Compartment internal dimensions in cm. Fixed per size — see [Compartment sizes](#compartment-sizes). | float |
| max_volumetric_weight | Maximum volumetric weight the compartment holds, in kg. | float |
| type | Compartment size, capitalised: `Small`, `Medium`, or `Large`. | string |

**Location fields (as returned here):**

| Key | Description | Type |
| --- | --- | --- |
| id | Unique identifier for the location. | integer |
| name | Internal location name. | string |
| human_name | Display name for the location. | string |
| type | `bob-box-locker`. An account user cannot reserve at a `bob-box-counter`. | string |
| description | Free-text description, e.g. where in the building it is. | string |
| structured_address | Structured address fields. Same shape as on `GET /account/locations` — see the Locations section for the field list. | object |
| address | Human-readable address string. | string |
| structured_trading_hours | Per-day opening and closing times. Empty start/end time means closed that day. | object |
| trading_hours | Human-readable trading hours string. | string |
| image_urls | Location image URLs. | array of string |
| status | The location's status, e.g. `active`. | string |
| compartment_errors | Reasons no compartment could serve the request, when applicable. | array of string |
| decommissioning_date | When the location will be decommissioned. ISO 8601 UTC timestamp, only present when `status` is `decommissioning`. See "Decommissioning locations" in the Locations section. | string (datetime) |

This object does **not** carry `s3_image_url`, `s3_image_urls`, `hidden`, `compartment_availability`, `time_created`, or `time_modified` — those belong to `GET /account/locations` only.

#### Using the PINs

Store the three PINs on your side and use them however suits your workflow: show them in your app, send them by SMS or email, print them on a label, or pass them to your courier's system. Each PIN is for a different person at a different point in the parcel's journey:

| PIN | Who uses it | When |
| --- | --- | --- |
| **Drop-off PIN** | The person placing the parcel in the locker. | To open the compartment and put the parcel in. |
| **Pickup PIN** | The person collecting the parcel. | To open the compartment and take the parcel out. |
| **Return PIN** | Your courier, or the Bob Box team if the compartment needs to be cleared. | When the parcel was never collected and has to be taken out of the locker. |

Things to know:

- **Only give each PIN to the person who needs it.** Anyone with a PIN can open the compartment at that point, so the PIN is what keeps the parcel safe.
- **The drop-off PIN only works until the parcel is dropped off.** After that, the locker no longer accepts it.
- **PINs can change.** If a reservation's PINs are reissued, the **Pin reissued** webhook sends you the new PINs, so subscribe to it if you store PINs. Replace the PINs you stored, and make sure anyone you already sent them to gets the new ones. See the Webhooks section.

#### Errors

| Status | Message | Cause |
| --- | --- | --- |
| 400 | `location_id is required` | Neither `location_id` nor `bob_box_location_id` was sent. |
| 400 | `reservation_type is required` | `reservation_type` was missing. |
| 400 | `invalid reservation_type` | Not one of the three accepted values. |
| 400 | `could not find location for location ID` | No location with that ID. |
| 400 | `location is not active` | The location is `draft`, `prospect`, `inactive`, or `decommissioned`. |
| 400 | `location is being decommissioned and is no longer accepting new reservations` | The location's decommissioning date has passed. |
| 400 | `Parcel dimensions exceed maximum compartment size` | The parcel does not fit the largest compartment. |
| 400 | `Parcel matched compartment size <size>; no <sizes> compartments available at this location` | Nothing free in the sizes that were checked. With `allow_auto_upgrade: false` only the matched size is checked. |
| 400 | `There is already an active reservation with this reference at this location.` | Duplicate `reference` at that location. |
| 400 | `cannot create a reservation for a counter` | An account user tried to reserve at a `bob-box-counter` location. |
| 400 | `invalid contact_number` | `contact_number` could not be read as a phone number at all. |
| 400 | `contact_number must be a valid South African phone number` | The number was read, but is not valid for South Africa. |
| 400 | `invalid custom_data` | `custom_data` could not be turned into JSON. |
| 400 | `parcel_count cannot be negative` | A negative `parcel_count` was sent. |
| 400 | `account_id is required when creating a reservation` | Your token carries no account, so there is nothing to bill the reservation to. |
| 400 | `counter is not available` | A counter location has reached its shipment limit. |
| 400 | `reference must be at most 255 characters` / `contact_name must be at most 255 characters` / `custom_data must be at most 4096 bytes when serialized` | A length limit was exceeded. |
| 403 | `this location is not available for the specified account` | The location is held for another account. Treat it as if it does not exist. |
| 403 | `account is frozen` | New reservations are limited while an account is frozen. Existing reservations are unaffected. |
