# Welcome to the Bob Box API **The Bob Box API is the gateway to South Africa's courier-agnostic smart locker network.** It lets couriers, e-commerce platforms, and logistics systems find lockers, reserve compartments, and follow a parcel through dropoff and collection. By integrating, your platform gains access to a growing nationwide locker network, and your customers get delivery and collection that does not depend on anyone being home. ## What you can do - **Find lockers.** Search the live network by coordinates and radius, by bounding box, or by name. Every location comes back with its address, GPS coordinates, trading hours, photos, and status. - **Check space before you commit.** Ask for availability by parcel size and get back which compartment sizes are actually free at each location right now. - **Reserve a compartment.** Send your parcel dimensions and Bob Box matches them to a compartment size. You get the compartment, the PINs, and the billing window back in one call. - **Hand over PINs.** Every reservation carries a dropoff, pickup, and return PIN. Give your customer the one they need. - **Follow the parcel.** Subscribe to webhooks for dropoff, collection, return, clearing, and PIN changes — or poll the reservation directly. - **Cancel cleanly.** Release a compartment you no longer need, as long as the parcel is not already inside. - **Attach your own data.** Put a JSON object on a reservation and it comes back on every read and every webhook, so you can tie a reservation to your own records without a lookup table. ## How to read these docs | Section | What it covers | | --- | --- | | **Workflows** | The delivery patterns the network supports, and which reservation type each one uses. Start here to work out what you are building. | | **Getting started** | The shortest path from an API key to a live reservation, in six curl commands. | | **Authentication** | Getting and securely using an API key for your integration. | | **Locations** | Finding lockers available to your account. | | **Reservations** | Creating, reading, and cancelling reservations, and fetching PINs. | | **Webhooks** | Getting status changes pushed to your own URL, and verifying they came from us. | ## Base URL Every request goes to the same host. **Base URL:** `https://api.bobbox.co.za/v2/` ## Access Every request carries `Authorization: Bearer `. Your API key is issued once and never expires. - **Create your own key.** Sign in to the dashboard as the Bob Box account owner and open **Settings → API keys**. The key is shown in full once, at that moment — copy it then. - **Or ask your Bob Box contact.** They will set up an API user for your account and give you the key. Either way, the Authentication section has the full flow and the rules for keeping the key safe. ## Conventions - All requests and responses are JSON, over HTTPS only. - Timestamps are ISO 8601. - Dimensions are centimetres. Volumetric weight is kilograms, at a volumetric factor of 4000 — `length × width × height ÷ 4000`. - List endpoints return `{ "": [...], "count": N }`, where `count` ignores `limit` and `offset` so you can page. When nothing matches, the array may be left out of the body entirely, so read `count` and treat a missing array as empty. - List filters that accept several values take a JSON array, e.g. `status=["waiting-for-dropoff","waiting-for-pickup"]`. - Limits are imposed while an account is frozen: `GET /account/locations` and `POST /bob-box/reservations` return `403 Account is frozen`. Reading, cancelling, and fetching PINs for existing reservations are unaffected. - Errors return a non-2xx status and a JSON body shaped `{ "message": "..." }`. The message is capitalised on the way out, so `id is required` reaches you as `{ "message": "Id is required" }`. Each endpoint page lists its own messages in their uncapitalised source form. Authentication and permission failures are the exception — they are returned before the handler runs, as plain text. ## Support If you need help at any stage of your integration, reach out to your Bob account manager, or contact us at `support@bob.co.za`. --- # Integration workflows The delivery patterns the Bob Box network supports, and how each one maps onto the API. Read this before you build — picking the right `reservation_type` up front saves rework, because it decides which PIN your customer needs and who acts at the locker. ## Picking a reservation type Every reservation declares who does what at the locker. The name describes **the client's** action, not the courier's. | `reservation_type` | The client (end customer) | The courier | Use it for | | --- | --- | --- | --- | | `client-pickup` | Collects the parcel | Drops the parcel off | Door-to-locker delivery | | `client-dropoff` | Drops the parcel off | Collects the parcel | Locker-to-door delivery | | `client-dropoff-and-pickup` | Drops off, then a different end user collects | Not involved at the locker | Locker-to-locker, and peer-to-peer | ## Door-to-locker delivery A parcel goes to a locker instead of an address. The most common courier flow. 1. **Find a locker with space.** `GET /account/locations` with the customer's coordinates, `max_distance`, the parcel dimensions, and `include_availability=true`. Filter to locations where the size your parcel matches is `true`. See [Checking space before you reserve](#checking-space-before-you-reserve). 2. **Reserve before you dispatch.** `POST /bob-box/reservations` with `reservation_type: "client-pickup"`. Do this before the driver leaves, so the compartment is held. 3. **Give the driver the `dropoff_pin`.** It is in the create response. 4. **Driver drops off.** The door closes and Bob Box fires the **Parcel dropped** webhook. Status moves to `waiting-for-pickup`. 5. **Notify the customer** with the `pickup_pin` and the locker address. If you enforce a collection deadline of your own, include it. 6. **Customer collects.** The **Parcel collected** webhook fires and the status becomes `complete`. The flat-fee billing starts at the moment a reservation is created. ## Locker-to-door collection The customer puts a parcel in a locker and a courier takes it away. Returns, and customers sending parcels. 1. **Reserve** with `reservation_type: "client-dropoff"`, and put your own tracking number in `reference`. 2. **Send the customer the `dropoff_pin`** and the locker address. 3. **Customer drops off.** The **Parcel dropped** webhook fires; status becomes `waiting-for-pickup`. 4. **Dispatch a driver** — the webhook is your trigger, so you are not sending anyone to an empty locker. 5. **Driver collects** with the `pickup_pin`. The **Parcel collected** webhook fires and the status becomes `complete`. 6. **Complete the delivery** to its final address on your own network. ## Locker-to-locker shipment Sender drops at one locker, recipient collects from another. Two reservations, one shipment. 1. **Sender picks a drop-off locker; recipient picks a collection locker.** Both from `GET /account/locations`, with the parcel dimensions and `include_availability=true` so you only offer lockers that can take the parcel. 2. **Create two reservations**, and link them with a shared `reference` or matching `custom_data`: - Origin locker, `reservation_type: "client-dropoff"` — the sender drops off, your driver collects. - Destination locker, `reservation_type: "client-pickup"` — your driver drops off, the recipient collects. 3. **Sender drops off** at the origin with its `dropoff_pin`. 4. **Driver collects** from the origin with its `pickup_pin`, then **drops off** at the destination with the destination reservation's `dropoff_pin`. 5. **Recipient collects** with the destination `pickup_pin`. Create the destination reservation up front so you know the space exists before the parcel is in your hands. If the destination locker fills up while the parcel is in transit, you have a parcel with nowhere to go. ## Checking space before you reserve Do not find out at reservation time that a locker is full. Add `include_availability=true` to `GET /account/locations` and each location comes back with a map: ```json "compartment_availability": { "Small": true, "Medium": false, "Large": true } ``` What `true` means depends on whether you send parcel dimensions. ### Scenario 1 — availability only ``` GET /account/locations ?lat=-26.107567&lng=28.053040&max_distance=10 &include_availability=true ``` `true` means only: **a compartment of that size is free.** No fit check runs, because you sent no parcel. You must size the parcel yourself and read the right key. Use this only if you already track compartment sizes on your side. ### Scenario 2 — availability plus dimensions (recommended) ``` GET /account/locations ?lat=-26.107567&lng=28.053040&max_distance=10 &parcel_length=30&parcel_width=20&parcel_height=10 &include_availability=true ``` `true` now means: **free, and your parcel fits**, in any orientation — the sides are sorted before they are compared, so how you order them does not matter. Sizes too small go `false`, even when compartments of that size are standing empty. ### Does the map predict the reservation? | Your create call | Map is accurate? | | --- | --- | | Dimensions + `allow_auto_upgrade: true` | **Yes.** Any `true` will work — both sides run the same rule. | | `allow_auto_upgrade: false` (default) | **No.** Create uses only the smallest size the parcel fits. The map marks larger sizes too, so `Small: false, Medium: true` is ambiguous. | Send dimensions and `allow_auto_upgrade: true`. That is the only combination where the check and the reservation agree. ### Watch out - **Dimensions never remove locations.** A location can come back with every size `false`. Filter those out yourself. - **The map is a snapshot.** Someone can take the last compartment before you reserve, so still handle create failures. ### Fallback Skip the check, call `POST /bob-box/reservations`, and read the failure message. It names the size your parcel matched and the sizes that were checked. It costs a round trip after your customer has already picked a locker, so use it as a backup only. The Create reservation page lists every failure message, and explains the matching itself under "How the parcel is sized". ## When the parcel does not fit How sizing works — the smallest-size match, and what `allow_auto_upgrade` changes — is documented once, on the Create reservation endpoint page under "How the parcel is sized". Read it before you build. This section covers only what to do when you get it wrong. If the parcel turns out not to fit the compartment you reserved: 1. `POST /bob-box/reservations/cancel` to release it. This works before dropoff only — the Cancel reservation endpoint page has the exact rule. 2. Create a new reservation with the corrected dimensions. There is no resize endpoint. Cancel and re-create is the supported path, which is another reason to measure before you reserve. ## Overstay and clearing A parcel left in a locker costs money, and eventually the compartment is cleared. Two clocks run, and both come back on the reservation: | Field | What happens at it | | --- | --- | | `flat_fee_end_time` | The flat-fee window closes and overstay charges begin. | | `intervention_time` | The reservation becomes eligible for Bob Box to clear the compartment. Sits after `flat_fee_end_time`. | When Bob Box intervenes after `intervention_time`, it will clear a compartment to free it up, the **Reservation cleared** webhook fires and the status becomes `cleared`. Clearing is an operations decision, not an automatic job, so it happens some time after `intervention_time` rather than at it. **Bob Box does not enforce a collection deadline.** A parcel stays collectable until it is collected, returned, or the compartment is cleared. If your business needs a deadline, set and enforce it on your side. Time your reminders against `flat_fee_end_time`, because that is the moment overstay charges start costing your customer money. If a parcel needs to go back to the sender, the `return_pin` opens the compartment and fires the **Reservation returned** webhook. ## Keeping your locker list fresh The network changes — lockers are installed, taken offline, and decommissioned. - **Sync `GET /account/locations` periodically** rather than hardcoding a list. Cache it, but re-fetch on a schedule. - **Watch `status`.** Only `active` and `decommissioning` locations accept new reservations. A location with a `decommissioning_date` in the past is rejected. - **Check availability at reservation time, not display time.** Re-run the check against a fresh `GET /account/locations` just before you reserve — see [Checking space before you reserve](#checking-space-before-you-reserve). - **Decide which lockers you service.** Use coordinates and your own service areas to filter the list down to the lockers your drivers actually reach. ## Notifications to end users **Customer notifications are your side of the integration by default.** Bob Box does not SMS, email, or WhatsApp your customers unless your account has been configured for it. You send: - The PIN they need, and which action it is for. - The locker address and its trading hours — `structured_trading_hours` is there to render, and an empty start and end time means closed that day. - Your own collection deadline, if you set one, and reminders before it. - A tracking link, if you have one. `contact_name` and `contact_number` on a reservation are for locker-side and support use. Setting them does not make Bob Box notify anyone while SMS is switched off for your account. **Bob Box can send the collection SMS for you.** If your account is configured for it, Bob Box sends the customer an SMS with the `pickup_pin` and the locker name as soon as the parcel is dropped off. It needs both `contact_name` and `contact_number` on the reservation. It is off unless it has been switched on — contact the Bob Box team to activate it for your account. ## Driver and operations support - **Give drivers a way to report a locker problem** — a jammed door, an offline unit, a compartment that will not open. Route it to Bob Box support rather than leaving the driver stuck at the locker. - **Handle the PIN-reissue webhook.** If Bob Box has to intervene at a locker, PINs can change. The **Pin reissued** webhook carries the new ones, and anything you have already sent a customer is stale. - **Log the `reservation_id`** against your own shipment record. It is what Bob Box support will ask for. ## Building it in the right order If you are starting from nothing, this is the order that surfaces problems earliest: 1. Authenticate and list locations. Confirms your key and your account's locker access. 2. Create and cancel a reservation on a dev locker. Confirms your sizing and field names. 3. Stand up a webhook endpoint and do a real dropoff on a dev locker. Confirms signature verification, which is the part most integrations get wrong. 4. Wire up your customer notifications. 5. Handle the unhappy paths: no space, overstay and clearing, PIN reissue. These flows are deliberately courier-agnostic. Following them keeps your integration predictable for Bob Box operations, and keeps the network working the same way for everyone on it. --- # Getting started This walks through the core integration flow: authenticate, find a location, create a reservation, and hand your customer the PIN that opens the locker. ## 1. Authenticate Every request carries `Authorization: Bearer `. Create an API key in the dashboard under **Settings → API keys**, or ask your Bob Box contact to issue one with an API user. It never expires: ```bash curl -G "https://api.bobbox.co.za/v2/account/locations" \ -H "Authorization: Bearer 4f3c9b1a72e04d5fa9c8e6b0d1a2f3c4" ``` That is all you need for the rest of this guide. ## 2. Find a location Look up the locker locations available to your account, and check which ones have space for your parcel before you try to reserve. **Endpoint:** `GET /account/locations` ```bash curl -G "https://api.bobbox.co.za/v2/account/locations" \ -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "lat=-26.107567" \ --data-urlencode "lng=28.053040" \ --data-urlencode "max_distance=10" \ --data-urlencode "order_closest=true" \ --data-urlencode "parcel_length=30" \ --data-urlencode "parcel_width=20" \ --data-urlencode "parcel_height=10" \ --data-urlencode "include_availability=true" ``` **Send the parcel dimensions together with `include_availability=true`.** That gives you a `compartment_availability` map per location: ```json "compartment_availability": { "Small": true, "Medium": true, "Large": false } ``` A size is `true` when a compartment of that size is free **and** your parcel fits it. Without dimensions, `include_availability=true` still reports which compartment sizes are free, but it does not check whether your parcel fits. Skip any location where every size is `false`. That is enough to finish this guide. What the map does and does not promise about step 3, and what to do when it is wrong, is in the Workflows section under "Checking space before you reserve". See the Get account locations endpoint in the Locations section for the full parameter and response reference. ## 3. Create a reservation Reserve a compartment at the chosen location. **Endpoint:** `POST /bob-box/reservations` ```bash curl -X POST "https://api.bobbox.co.za/v2/bob-box/reservations" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_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" }' ``` Two things to get right here: - **`reservation_type` is required.** Leave it out and the call fails. - **`allow_auto_upgrade` defaults to `false`,** which fails the reservation while bigger compartments stand empty. The sample above sends `true`. Why, and when not to, is on the Create reservation endpoint page under "How the parcel is sized". The response gives you the reservation's `id`, the `compartment` it claimed, and **all three PINs**. Store the `id` and your own `reference` so you can look the reservation up later. See the Create reservation endpoint in the Reservations section for the full parameter and response reference. ## 4. Give your customer the PIN The PINs come back on the create response above — you do not need a second call: | PIN | Opens the compartment for | | --- | --- | | `dropoff_pin` | Putting the parcel in | | `pickup_pin` | Collecting the parcel | | `return_pin` | Sending the parcel back to the sender | Send the customer the one that matches what they are doing. Notifying customers is your side of the integration by default — Bob Box does not SMS or email them unless your account is configured for it. Contact the Bob Box team if you want Bob Box to send the collection SMS instead. If you ever need the PINs again without pulling the whole reservation, there is a dedicated endpoint: **Endpoint:** `GET /bob-box/locations/reservations/pins` ```bash curl -G "https://api.bobbox.co.za/v2/bob-box/locations/reservations/pins" \ -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "id=8931" ``` ## 5. Track status Bob Box can push reservation status changes to your own URL via webhooks — see the Webhooks section. You can also poll the reservation directly. **Endpoint:** `GET /bob-box/reservations` ```bash curl -G "https://api.bobbox.co.za/v2/bob-box/reservations" \ -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "id=8931" ``` #### Reservation statuses | Status | Description | | --- | --- | | `waiting-for-dropoff` | Reservation created; parcel not yet dropped off. | | `waiting-for-pickup` | Parcel dropped off; waiting for the recipient to collect it. | | `complete` | Parcel was collected. | | `expired` | Legacy status, set only by Bob Box operations. Not part of the normal lifecycle — do not build against it. | | `cancelled` | The reservation was cancelled. | | `returned` | The parcel was returned to the sender. | | `cleared` | The reservation was manually cleared. | Every reservation also carries `flat_fee_end_time` — the moment the flat-fee window closes and overstay charges start. Watch it if overstay cost matters to you. See the Get reservations endpoint in the Reservations section for the full field reference. ## 6. Cancel, if needed If the parcel is no longer being sent, cancel the reservation to free up the compartment. **Endpoint:** `POST /bob-box/reservations/cancel` ```bash curl -X POST "https://api.bobbox.co.za/v2/bob-box/reservations/cancel" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d '{ "reservation_id": 8931 }' ``` **This only works while the reservation is still `waiting-for-dropoff`.** Once the parcel is in the locker it cannot be cancelled — the parcel has to be collected or returned instead. See the Cancel reservation endpoint in the Reservations section for details. ## Next steps - **Webhooks section** — get reservation status changes pushed to your own URL instead of polling. --- # Authentication Bob Box integrations use an API key as a bearer token. Every request carries the same header: ```http Authorization: Bearer ``` | Credential | Who it's for | How you get it | Expires | | --- | --- | --- | --- | | **API key** | Server-to-server integrations (courier and delivery platforms) | Created under **Settings → API keys**, or issued with an API user. | No | API keys resolve to the account and permissions assigned to them. An unknown or revoked token returns `401`, with the body `Authentication error: authentication failed, please check your API key`. Sending no `Authorization` header returns `403`, with `You're not logged in. Please log in and try again.` A valid API key whose role lacks the endpoint's permission also returns `403`, with `You do not have access to the requested resource`. All three are plain text, not the JSON `{ "message": ... }` envelope the rest of the API uses — they are returned before the handler runs. ## API keys ### Getting a key You can create your own key. Sign in to the dashboard as the Bob Box account owner, open **Settings → API keys**, and create one. The same page deletes keys you no longer want. Your Bob Box contact can also create an **API user** for you instead. Either way, the key is shown once, in full, at that moment. **Copy it then and store it somewhere safe.** Afterwards it is only ever displayed masked, as `abc***xyz`. If it is lost, create a new key and delete the old one. ### Using it Send it as a bearer token on every request: ```bash curl -G "https://api.bobbox.co.za/v2/account/locations" \ -H "Authorization: Bearer 4f3c9b1a72e04d5fa9c8e6b0d1a2f3c4" ``` That is the whole authentication flow. ### What the key can do The API user carries a role, and that role decides which endpoints the key may call. A key issued for your account reads and writes your own account's locations and reservations. ### Keeping it safe - **Treat it as a password.** It grants full access to your account's data and never expires. - **Server-side only.** Never put it in a mobile app, browser JavaScript, or a public repository — there is no way to scope or time-limit it. - **HTTPS only.** The API is HTTPS-only; do not build anything that would send the key in the clear. - **Rotating means replacing.** A key cannot be rotated in place. Create a second key under **Settings → API keys**, cut your integration over to it, then delete the old one from the same page. --- # Locations The Locations API lets you find Bob Box locker locations — for showing pickup and dropoff points to your customers. - **GET account locations:** Retrieve locker locations available to your account, with optional distance, parcel-size, and availability filters. This is the primary endpoint for integrators (e.g. courier and delivery platforms) to list usable locations. --- # Get account locations `GET /v2/account/locations` **Base URLs:** - Production: `https://api.bobbox.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.bobbox.co.za/v2/account/locations' \ -H "Authorization: Bearer " ``` Retrieve locker locations available to your account. This is the primary endpoint for external integrators (courier and delivery platforms) to list usable Bob Box locations — for example to show pickup and dropoff points on a map, or to check whether a location can fit a given parcel before creating a reservation. Only active, non-hidden locker locations are returned by default. Limits are imposed while an account is frozen — this endpoint then returns `403 Account is frozen`. ### Request **Method:** GET **URL:** `https://api.bobbox.co.za/v2/account/locations` #### Query parameters | Key | Description | Type | Required | | --- | --- | --- | --- | | id | One or more location IDs, passed as a JSON array (e.g. `[213]`). | array of integer | No | | name | Case-insensitive partial match on the internal location name. Not an exact match. | string | No | | search_name | Case-insensitive partial match on either the internal `name` or the `human_name`. | string | No | | lat | Latitude to search near (used with `max_distance`). | float | No | | lng | Longitude to search near (used with `max_distance`). | float | No | | max_distance | Maximum distance in km from `lat`/`lng`. Leave it out and a per-location cap applies instead — see below. | float | No | | order_closest | If true, order results by distance from `lat`/`lng`. | boolean | No | | min_lat / max_lat / min_lng / max_lng | Bounding box filter, as an alternative to a radius search. | float | No | | include_availability | If true, include a `compartment_availability` map showing which compartment sizes currently have space. Required to see availability at all. | boolean | No | | show_hidden | If true, include locations normally hidden from listings. | boolean | No | | parcel_length / parcel_width / parcel_height | Parcel dimensions in cm. Marks a size `false` unless the parcel fits it. Only has an effect together with `include_availability=true`. | float | No | | volumetric_weight | Parcel volumetric weight in kg — `length × width × height ÷ 4000`, sides in cm. Used for fit checks when no dimensions are sent. | float | No | | limit | Limit the number of records returned. Must be 0 or more. | integer | No | | offset | The number of records to skip (for pagination). Must be 0 or more. | integer | No | | order_by | Field name to sort by. Must be a valid location field. | string | No | | order | Sort direction, `asc` or `desc`. | string | No | **The dimensions and `include_availability` work as a pair.** `compartment_availability` is the only field the dimensions shape, and it is left out entirely unless `include_availability=true`. Send both, or neither. - **Both sent** — a size is `true` when a compartment of that size is free **and** the parcel fits it. - **`include_availability` alone** — `true` means only that a compartment of that size is free. No fit check runs. - **Either way, dimensions never remove a location.** A location can come back with every size `false`. Filter those out yourself. **What the map does and does not promise about `POST /bob-box/reservations` is documented once**, in the Workflows section under "Checking space before you reserve". Read it before you rely on this field. **Sending `lat` and `lng` filters more than you asked for.** Two extra rules run whenever both coordinates are present: - **A distance cap always applies.** Send `max_distance` and you get that radius. Leave it out and each location is capped by its own service range instead, falling back to a platform default. The list is trimmed before you ever see it. **Always send `max_distance`,** so the radius is one you chose. - **A service-area check runs.** A location that defines a service area is dropped unless your point falls inside it. A location with no service area is always kept. Together these mean a locker a few hundred metres away can be missing from the result. If you expect a specific locker and do not see it, fetch it by `id` instead of by coordinates. ### Response **Status code:** 200 OK **Content-Type:** application/json **Response body example:** ```json { "bob_box_locations": [ { "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", "compartment_availability": { "Small": true, "Medium": true, "Large": false }, "s3_image_url": "https://images.bob.co.za/locations/213/main.jpg", "s3_image_urls": ["https://images.bob.co.za/locations/213/main.jpg"], "status": "active", "hidden": false, "time_created": "2023-05-02T06:00:00.000000Z", "time_modified": "2026-01-10T07:00:00.000000Z" } ], "count": 1 } ``` #### Response fields | Key | Description | Type | | --- | --- | --- | | bob_box_locations | Array of location objects matching the query. | array | | count | Total number of locations matching the query, ignoring `limit` and `offset`. | integer | **Location fields:** | Key | Description | Type | | --- | --- | --- | | id | Unique identifier for the location. | integer | | name | Internal location name. Not a URL slug — both `name` and `search_name` perform case-insensitive partial matches. | string | | human_name | Display name for the location. Use this in customer-facing UI. | string | | type | Always the exact string `bob-box-locker` on this endpoint. | string | | description | Free-text description, e.g. where in the building it is. | string | | structured_address | Structured address fields. See the table below. | object | | address | Human-readable address string. | string | | structured_trading_hours | Per-day opening and closing times (`monday`…`sunday`, `holidays`). Empty start/end time means closed that day. | object | | trading_hours | Human-readable trading hours string. | string | | compartment_availability | Map of compartment size to whether space is currently available. Keys are capitalised: `Small`, `Medium`, `Large`. Only present when `include_availability=true`. | object | | s3_image_url | Primary location image URL. | string | | s3_image_urls | All location image URLs. | array of string | | status | `active` or `decommissioning`. | string | | decommissioning_date | When the location will be decommissioned. ISO 8601 UTC timestamp, only present when `status` is `decommissioning`. See "Decommissioning locations" below. | string (datetime) | | hidden | Whether this location is excluded from default listings. | boolean | | time_created | When the location was created. | string (datetime) | | time_modified | When the location was last modified. | string (datetime) | **Structured address fields:** | Key | Description | Type | | --- | --- | --- | | entered_address | The address as it was captured, unparsed. | string | | company | Company or building name at the address. Often `null`. | string or null | | street_address | Street number and street name. | string or null | | local_area | Suburb or local area. | string or null | | city | City or town. | string or null | | zone | Province. | string or null | | code | Postal code. | string or null | | country | Two-letter country code, e.g. `ZA`. | string or null | | lat | Latitude. | float | | lng | Longitude. | float | Every field except `entered_address`, `lat`, and `lng` can come back `null`, so guard before you render. ### Decommissioning locations The `decommissioning_date` is an ISO 8601 UTC timestamp and is only present on locations with the status `decommissioning`. When a location is decommissioning: - Reservations are still allowed up until the `decommissioning_date`. - Once the `decommissioning_date` has been reached, reservations will no longer be allowed at the location. `POST /bob-box/reservations` then returns `400 location is being decommissioned and is no longer accepting new reservations`. It is up to the integrator to decide how far in advance of the `decommissioning_date` they would like to stop making reservations to the location. Allow enough lead time for parcels to still be dropped off and collected before the locker is removed. **This endpoint only ever returns lockers.** `type` is always the exact string `bob-box-locker` — not `locker`. The other location type, `bob-box-counter`, exists in the system but is filtered out here, so you never have to handle it. --- # Reservations The Reservations API is the core of the Bob Box integration — reserving a locker compartment for a parcel, looking up reservations, and cancelling one. - **POST create reservation:** Reserve a compartment at a location for a parcel. The response includes the compartment and all three PINs. - **GET reservations:** Retrieve your reservations, filterable by location, status, or reference. - **GET reservation pins:** Re-fetch just the PINs for one reservation. Optional — create and list already return them. - **POST cancel reservation:** Cancel a reservation that has not been dropped off yet. **The PINs path looks like a typo. It is not.** Reservations sit at `/bob-box/reservations`, but the PINs endpoint sits at `/bob-box/locations/reservations/pins` — with `locations` in the middle. Copy it exactly. Instead of polling for status changes, you can have them pushed to your own URL — see the Webhooks section. --- # 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 " \ -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 ; no 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. | --- # Get reservations `GET /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 GET 'https://api.bobbox.co.za/v2/bob-box/reservations' \ -H "Authorization: Bearer " ``` Retrieve your account's reservations, filterable by location, status, reference, or a named date range. ### Request **Method:** GET **URL:** `https://api.bobbox.co.za/v2/bob-box/reservations` #### Query parameters | Key | Description | Type | Required | | --- | --- | --- | --- | | id | One or more reservation IDs, passed as a JSON array (e.g. `[8931]`). A single bare integer also works. | array of integer | No | | bob_box_location_id | Restrict to reservations at this location. | integer | No | | status | One or more statuses, passed as a JSON array (e.g. `["waiting-for-dropoff","waiting-for-pickup"]`). | array of string | No | | custom_reference | Case-insensitive partial match on the reservation's reference. Not an exact match. | string | No | | custom_data_search | Case-insensitive partial match within `custom_data`. | string | No | | absolute_query | A named date range over `time_created`. See the accepted values below. | string | No | | limit | Limit the number of records returned. Defaults to **20** whenever no `id` is given. Send it, with `offset`, to page through more. Must be 0 or more. | integer | No | | offset | The number of records to skip (for pagination). Must be 0 or more. | integer | No | | order_by | Field name to sort by. Must be a valid reservation field. | string | No | | order | Sort direction, `asc` or `desc`. | string | No | Reservations are always scoped to your own account — you cannot read another account's reservations. **There is no arbitrary date range.** `start_date` and `end_date` are accepted by the endpoint and then ignored — they filter nothing. Use `absolute_query`, or fetch a wider set and filter on `time_created` yourself. #### Accepted `absolute_query` values This is a fixed set of patterns, not free-form text. Anything else returns `invalid absolute date query`. | Value | Range | | --- | --- | | `yesterday` | Yesterday, 00:00 to 23:59 | | `last day` | Today, 00:00 to 23:59 | | `last N hours` | N hours ago onwards. No end bound, e.g. `last 6 hours` | | `last N days` | N days ago onwards. No end bound, e.g. `last 7 days` | | `last month` | The current calendar month | ### Response **Status code:** 200 OK **Content-Type:** application/json **Response body example:** ```json { "reservations": [ { "id": 8931, "status": "waiting-for-pickup", "type": "client-dropoff", "bob_box_location": { "id": 213, "name": "sandton_city", "human_name": "Sandton City", "type": "bob-box-locker", "address": "83 Rivonia Rd, Sandhurst, Sandton, 2196", "status": "active" }, "locker_id": 44, "compartment": { "number": 12, "width": 32, "length": 56, "height": 10, "max_volumetric_weight": 4.48, "type": "Small" }, "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": "2026-08-25T09:12:00+02:00", "time_completed": null, "time_created": "2026-08-25T11:40:00+02:00", "time_modified": "2026-08-25T09:12:00+02:00" } ], "count": 1 } ``` The `bob_box_location` object above is trimmed for readability. It carries the same fields as in the Create reservation response — see that endpoint for the full list. #### Response fields | Key | Description | Type | | --- | --- | --- | | reservations | Array of reservation objects matching the query. Left out of the body entirely when nothing matches, so read `count`. | array | | count | Total number of reservations matching the query, ignoring `limit` and `offset`. | integer | Each reservation has the same field shape as the Create reservation response, reflecting its current status. **About the PINs:** all three PINs (`dropoff_pin`, `pickup_pin`, `return_pin`) are returned for your own reservations, at every status — they do not appear and disappear as the reservation progresses. PINs are blanked out only on reservations belonging to a different account. --- # Get reservation PINs `GET /v2/bob-box/locations/reservations/pins` **Base URLs:** - Production: `https://api.bobbox.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.bobbox.co.za/v2/bob-box/locations/reservations/pins' \ -H "Authorization: Bearer " ``` Re-fetch the PINs for a reservation. > **You usually do not need this endpoint.** All three PINs are already in the Create reservation response and in every Get reservations response. Use this when you want just the PINs — for example to re-send one to a customer without pulling the whole reservation. ### Request **Method:** GET **URL:** `https://api.bobbox.co.za/v2/bob-box/locations/reservations/pins` #### Query parameters | Key | Description | Type | Required | | --- | --- | --- | --- | | id | The ID of the reservation. | integer | Yes | ### Response **Status code:** 200 OK **Content-Type:** application/json **Response body example:** ```json { "dropoff_pin": "482150", "pickup_pin": "903417", "return_pin": "118736" } ``` #### Response fields | Key | Description | Type | | --- | --- | --- | | dropoff_pin | PIN that opens the compartment for dropoff. | string | | pickup_pin | PIN that opens the compartment for collection. | string | | return_pin | PIN that opens the compartment to return the parcel to the sender. | string | All three PINs are returned, at every reservation status. A field is omitted only when that PIN is genuinely not set on the reservation. **Counter operators are the exception.** A user with the counter operator role gets exactly one PIN, chosen by reservation type: the `dropoff_pin` for a `client-pickup` reservation, or the `pickup_pin` for a `client-dropoff` one. Any other reservation type returns `400 invalid reservation type to retrieve reservation PIN`. Every call is recorded as a location event against the calling user. #### Errors | Status | Message | Cause | | --- | --- | --- | | 400 | `id is required` | No `id` was sent. | | 400 | `reservation not found` | No reservation exists with that ID. | | 400 | `invalid reservation type to retrieve reservation PIN` | Counter operators only — see the note above. | --- # Cancel reservation `POST /v2/bob-box/reservations/cancel` **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/cancel' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "reservation_id": 3475 }' ``` Cancel a reservation that has not been dropped off yet, freeing up the compartment. > **Only a reservation still in `waiting-for-dropoff` can be cancelled.** Once the parcel is in the locker there is nothing to cancel — every other status, `waiting-for-pickup` included, is rejected. ### Request **Method:** POST **URL:** `https://api.bobbox.co.za/v2/bob-box/reservations/cancel` #### Request body parameters | Key | Description | Type | Required | | --- | --- | --- | --- | | reservation_id | The ID of the reservation to cancel. | integer | Conditional* | | id | Alias for `reservation_id`. | integer | Conditional* | \* Send either `reservation_id` or `id`. If you send both, `id` wins. ### Response **Status code:** 200 OK **Content-Type:** application/json **Response body:** ```json "success" ``` The body is a plain JSON string, not an object. #### Errors | Status | Message | Cause | | --- | --- | --- | | 400 | `id is required` | Neither `reservation_id` nor `id` was sent. | | 400 | `could not find reservation` | No reservation with that ID. | | 400 | `cannot cancel a reservation that is not waiting for dropoff` | The reservation is already dropped off, complete, expired, cancelled, returned, or cleared. | | 403 | `cannot cancel reservations for a different account` | The reservation belongs to another account. | --- # Webhooks Instead of polling the Get reservations endpoint, you can have Bob Box push a `POST` to your own URL whenever a reservation's status changes or its PINs are reissued. ## How it works 1. You configure a URL for each event you care about, in the dashboard. 2. When that event happens on one of your reservations, Bob Box sends a `POST` with a JSON body to that URL. 3. Your endpoint returns `200 OK` to acknowledge it. Anything else is retried. ## The events Each one's exact trigger and payload is in section 5 below. | Event | Fires when | Setting name | | --- | --- | --- | | **Parcel dropped** | The parcel is put into the locker | `webhook_reservation_dropoff_url` | | **Parcel collected** | The recipient collects it | `webhook_reservation_pickup_url` | | **Reservation returned** | The parcel is removed to go back to the sender | `webhook_reservation_return_url` | | **Reservation cleared** | Bob Box empties the compartment | `webhook_reservation_cleared_url` | | **Pin reissued** | Any PIN changes | `webhook_reservation_pins_reissued_url` | **There is no `event_type` field in the body.** You know which event it is from which of your URLs received the request, so give each event a distinct URL. Every payload shares these four fields, plus one timestamp specific to the event: | Field | Type | Description | | --- | --- | --- | | `reservation_id` | integer | The reservation's ID. | | `custom_reference` | string | Your own reference for this reservation, if you set one. | | `custom_data` | object | Any custom data you attached to the reservation. | | `reservation_status` | string | The reservation's status at the time of the event. | A "manual update" — a status changed through the API rather than by locker hardware — fires the same webhook as the hardware path. Handle both identically. ## 1. Set up webhooks in the dashboard This is the only way to generate your signing secret. **You must be signed in as the Bob Box account owner.** An account user cannot do this. If you are a super admin, you cannot do it from the internal Accounts pages either — impersonate the account owner first. ### Step 1 — Create and save the webhook secret 1. In the left menu, click **Settings** (cog icon). 2. Open the **Webhook auth credentials** tab. 3. Click **Create webhook secret**. (If one already exists, the button says **Regenerate webhook secret**.) 4. A popup shows your new secret. Click the copy icon. 5. **Save it somewhere safe now — you cannot get it back.** If you lose it you must regenerate, and the old one stops working immediately. 6. Close the popup. From now on you only see a masked version. This secret is the key for the `X-Webhook-Signature` HMAC-SHA256 signature on every webhook we send you. ### Step 2 — Add your webhook URLs 1. Still in **Settings**, open the **General** tab. 2. Under **Webhooks**, fill in the URLs you want. Leave a field blank to switch that event off. 3. Click **Save**. Each URL field saves independently, so one bad URL will not block the others. Example URL format: `https://example.com/reservation-dropoff` A blank URL means the event is simply not sent — nothing fails or errors on the reservation side. ### Step 3 — Check it works Do a real dropoff on a test reservation. We `POST` to your "Parcel dropped off" URL about 2 seconds later. Verify the signature using the secret from Step 1. **If webhooks stop right after you regenerate a secret,** check that your endpoint was updated — it is still checking the old one. ## 2. The webhook request **Method:** `POST` **Content-Type:** `application/json` Every request carries these headers: ```http Content-Type: application/json X-BobGroup-Type: webhook X-Webhook-Timestamp: 1756288800000 X-Webhook-Signature: v1=5d41402abc4b2a76b9719d911017c592... X-Auth-Hash: c592a76b9719d911017... ``` `X-Webhook-Timestamp` is a Unix timestamp in **milliseconds**. `X-Webhook-Signature` and `X-Auth-Hash` are only sent if you have created a webhook secret. Without one, the request arrives unsigned and you cannot verify it — so set one up. ## 3. Verifying the request came from Bob Box ### Recommended: verify `X-Webhook-Signature` An HMAC-SHA256 signature, sent as `v1=`, computed over the timestamp and the raw request body: ```text signature = hex( HMAC-SHA256( secret, timestamp + "." + raw_request_body ) ) ``` **Example — Node.js verification:** ```javascript const crypto = require("crypto"); function verifyWebhookSignature(rawBody, timestamp, signatureHeader, secret) { // signatureHeader looks like "v1=" const receivedSignature = signatureHeader.replace(/^v1=/, ""); const expectedSignature = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(receivedSignature, "hex"), Buffer.from(expectedSignature, "hex") ); } ``` **Use the raw, unparsed request body.** Re-serializing parsed JSON can change field order or spacing and break the comparison. This is the single most common webhook integration bug. `X-Auth-Hash` is an older scheme kept for backwards compatibility (`SHA1(hex(MD5(body)) + secret)`). Prefer `X-Webhook-Signature` for new integrations. ## 4. Responding to webhooks Return `200 OK` as soon as you have received and stored the webhook. Do slow processing afterwards, asynchronously. If your endpoint does not return `200 OK`, Bob Box retries the same request **roughly once a minute, up to 9 times**. There is no exponential backoff — it is a flat one-minute gap. After that the delivery is handed back to the queue, so a small number of further attempts is possible before it stops. **Make your endpoint idempotent.** Use `reservation_id` plus the event's timestamp field to detect a duplicate you have already handled. ## 5. The events in detail ### Parcel dropped Sent when a parcel is put into the locker. **Dashboard field:** Parcel dropped off **Setting name:** `webhook_reservation_dropoff_url` #### When it fires The locker door closes after a `dropoff_pin` was used, and the reservation is now `waiting-for-pickup`. It also fires when a reservation is manually updated to `waiting-for-pickup` through the API. Treat both the same way. #### Payload ```json { "reservation_id": 8931, "custom_reference": "ORDER-1452", "custom_data": {}, "reservation_status": "waiting-for-pickup", "dropoff_time": "2026-08-27T09:12:00+02:00" } ``` | Field | Type | Description | | --- | --- | --- | | reservation_id | integer | The reservation's ID. | | custom_reference | string | Your own reference for this reservation, if you set one. | | custom_data | object | Any custom data you attached to the reservation. | | reservation_status | string | Always `waiting-for-pickup` for this event. | | dropoff_time | string (datetime) | When the parcel was dropped off. | #### What to do with it - Send the recipient their `pickup_pin` and the locker address. - On a `client-dropoff` reservation, this is your signal to dispatch a driver — the parcel is now actually in the locker. ### Parcel collected Sent when the parcel is taken out of the locker by its recipient. **Dashboard field:** Parcel collected **Setting name:** `webhook_reservation_pickup_url` #### When it fires The locker door opens with a `pickup_pin`, and the reservation is now `complete`. It also fires on a manual update to `complete` through the API. Treat both the same way. #### Payload ```json { "reservation_id": 8931, "custom_reference": "ORDER-1452", "custom_data": {}, "reservation_status": "complete", "pickup_time": "2026-08-27T14:03:00+02:00" } ``` | Field | Type | Description | | --- | --- | --- | | reservation_id | integer | The reservation's ID. | | custom_reference | string | Your own reference for this reservation, if you set one. | | custom_data | object | Any custom data you attached to the reservation. | | reservation_status | string | Always `complete` for this event. | | pickup_time | string (datetime) | When the parcel was collected. | #### What to do with it - Mark the delivery complete on your side and stop any collection reminders. - On a `client-dropoff` reservation, this fires when **your driver** collects — the parcel is now on your network, not the customer's doorstep. ### Reservation returned Sent when the parcel is taken out of the locker to go back to the sender. **Dashboard field:** Parcel returned **Setting name:** `webhook_reservation_return_url` #### When it fires The locker door opens with a `return_pin`, and the reservation is now `returned`. It also fires on a manual update to `returned` through the API. Treat both the same way. #### Payload ```json { "reservation_id": 8931, "custom_reference": "ORDER-1452", "custom_data": {}, "reservation_status": "returned", "return_time": "2026-08-27T16:40:00+02:00" } ``` | Field | Type | Description | | --- | --- | --- | | reservation_id | integer | The reservation's ID. | | custom_reference | string | Your own reference for this reservation, if you set one. | | custom_data | object | Any custom data you attached to the reservation. | | reservation_status | string | Always `returned` for this event. | | return_time | string (datetime) | When the parcel was removed for return. | #### What to do with it - Route the parcel back to the sender on your own network. - This is a distinct outcome from `complete` — the recipient never got the parcel. Do not close the order as delivered. ### Reservation cleared Sent when Bob Box empties a compartment to free it up. This happens when a parcel is left in the locker long past its `intervention_time`. **Dashboard field:** Reservation cleared **Setting name:** `webhook_reservation_cleared_url` #### When it fires A compartment is cleared and the reservation's status becomes `cleared`. It also fires on a manual update to `cleared` through the API. Treat both the same way. A reservation only becomes eligible for clearing after its `intervention_time`, which sits after `flat_fee_end_time`. Both are on the reservation, so you can see it coming. Clearing is an operations decision, not an automatic job, so it happens some time after `intervention_time` rather than at it. #### Payload ```json { "reservation_id": 8931, "custom_reference": "ORDER-1452", "custom_data": {}, "reservation_status": "cleared", "clear_time": "2026-08-27T18:00:00+02:00" } ``` | Field | Type | Description | | --- | --- | --- | | reservation_id | integer | The reservation's ID. | | custom_reference | string | Your own reference for this reservation, if you set one. | | custom_data | object | Any custom data you attached to the reservation. | | reservation_status | string | Always `cleared` for this event. | | clear_time | string (datetime) | When the compartment was cleared. | #### What to do with it - **The parcel is no longer in the locker.** Contact Bob Box support with the `reservation_id` to find out where it went. - Treat this as an exception, not a normal ending. If you are seeing it regularly, your collection reminders are firing too late. ### Pin reissued Sent when a reservation's PINs change. Uncommon — it only happens when there is a problem at a locker and Bob Box has to intervene. **Dashboard field:** Reservation pins reissued **Setting name:** `webhook_reservation_pins_reissued_url` #### When it fires Any of the dropoff, pickup, or return PINs change — for example when a reservation is moved to a different compartment because the original door will not open. #### Payload This event carries more fields than the others, **including the live PINs**. Treat it as sensitive and keep your endpoint secure. ```json { "reservation_id": 8931, "custom_reference": "ORDER-1452", "custom_data": {}, "reservation_status": "waiting-for-pickup", "compartment_number": 14, "dropoff_pin": "482150", "pickup_pin": "903417", "return_pin": "118736", "time": "2026-08-27T10:15:00+02:00" } ``` | Field | Type | Description | | --- | --- | --- | | reservation_id | integer | The reservation's ID. | | custom_reference | string | Your own reference for this reservation, if you set one. | | custom_data | object | Any custom data you attached to the reservation. | | reservation_status | string | The reservation's status at the time of the change. | | compartment_number | integer | The compartment the reservation now occupies. It may have moved. | | dropoff_pin | string | The new dropoff PIN. | | pickup_pin | string | The new pickup PIN. | | return_pin | string | The new return PIN. | | time | string (datetime) | When the PINs were reissued. | #### What to do with it - **Any PIN you have already sent a customer is now dead.** Send the new one, and say why. - **`compartment_number` may have changed too.** If you told the customer a compartment number, correct it. - Handle this event even though it is rare. Skipping it means a customer standing at a locker with a PIN that does not work. ## 6. Best practices - **Secure your endpoint.** Verify the signature on every request; do not trust the body alone. - **Respond fast.** Acknowledge, queue, then process. - **Be resilient.** Queue and retry on your side too, so a downstream failure does not lose the event. - **Keep an audit log.** Record every webhook received and the status you returned. It makes debugging possible. ## 7. Troubleshooting - **Not receiving webhooks?** Confirm the URL for that event is filled in and saved under Settings → General → Webhooks. An empty URL means the event is silently skipped. - **No signature headers?** You have not created a webhook secret yet — see Step 1. - **Signature never matches?** You are almost certainly hashing a re-serialized body. Capture the raw bytes before your JSON parser touches them. - **Getting the same event twice?** Make sure your endpoint returns `200 OK` reliably and quickly. A slow or failing endpoint gets retried.