# Bob Go API The Bob Go API lets you rate, book and track shipments across every courier Bob Go supports, using a single integration. Everything the dashboard does is available over the same HTTP API. What you can build with it: - **Rates at checkout**: show your buyers the shipping options you have configured in Bob Go, priced by your own rules, while they are still in the cart. - **Order synchronisation**: push orders into Bob Go as they are placed, and keep them in step as they change. - **Shipment submission**: quote couriers, book the one you want, and pull the waybill. - **Tracking**: read a shipment's tracking events, or have Bob Go push them to you as they happen. ## Base URLs | Environment | Base URL | | --- | --- | | Sandbox | `https://api.sandbox.bobgo.co.za/v2` | | Production | `https://api.bobgo.co.za/v2` | Sandbox is a full copy of the platform wired to test courier accounts. Nothing you create there is collected or billed, and the account is pre-loaded with credit so you can create as many test shipments as you like. Sign up at [sandbox.bobgo.co.za](https://sandbox.bobgo.co.za/) and create a key under **Settings → API keys**. Going live is a two-line change: swap the base URL and swap the API key. The request and response shapes are identical. In this Bruno collection the environment sets `BobGoHost` to the host, and each request carries the `/v2` prefix in its path. ## Conventions - All requests and responses are JSON. Send `content-type: application/json` on any request with a body. - Paths are prefixed with `/v2`, e.g. `POST /v2/rates`, `POST /v2/shipments`. - Field names are `snake_case`. - Money is a number in ZAR (rands), e.g. `152.35`. Amounts are VAT-inclusive unless the field name says otherwise (`rate_amount` vs `rate_amount_excl_vat`). - Dimensions are centimetres (`_cm`), weights are kilograms (`_kg`). - Timestamps are RFC 3339, e.g. `2026-09-05T08:30:00Z`. - An array in the query string is written as a JSON array with no spaces, e.g. `?status=["in-transit","delivered"]`. A space after a comma is not rejected, but the value then matches nothing: a list comes back empty, and a waybill request finds no shipments to print. - Every request acts on the account your API key belongs to. Bob Go reads the account from the key, so you do not send `account_id`, and a value you do send is replaced rather than checked. Objects that appear in more than one endpoint, addresses and parcels, always have the same shape wherever they appear. See [Important concepts](../4_Important%20concepts/README.md). ## Listing and pagination List endpoints accept `limit` and `offset`, plus `order_by` and `order` (`asc` / `desc`). `order` only applies together with `order_by`: send both, and the sort is ascending unless `order` is `desc`. Without `order_by` the endpoint's default sort is used and `order` is ignored. Responses wrap the page in an object with a `count` of the whole result set, not of the page. This is `GET /v2/shipments?limit=2` on an account with 348 shipments, trimmed to two fields a shipment: ```json { "shipments": [ { "id": 4455123, "tracking_reference": "UATCG4X7Q2" }, { "id": 4455122, "tracking_reference": "UATCG4X7PZ" } ], "count": 348 } ``` Defaults and caps are per endpoint, so read the page for the one you are calling. `GET /v2/orders` and `GET /v2/shipments` both default to 100 and cap at 100. `GET /v2/packages` has no default and no cap: it returns every package on the account unless you set `limit` yourself. ## Errors Failures return an HTTP status code and a single message field: ```json { "message": "Collection contact name is required." } ``` | Status | Meaning | | --- | --- | | `400` | The request failed validation. The message says what. | | `401` | The API key was not recognised. See [Authentication](../2_Authentication/README.md#failures). | | `403` | No API key was sent, the key may not make this call, or your plan does not include API access. | | `404` | No such path, or no such record on your account. Some endpoints report a missing record as a `400` or an empty list instead. | | `423` | The account has been closed. | | `429` | Too many requests on this key. Back off and retry. | | `500` | Something failed on our side. A few validation failures also come back as `500`, so read the message before retrying. | | `503` | The request ran past the gateway's limit of about 30 seconds, usually while waiting on a courier. The body is `{"message": "Service Unavailable"}`. Retry later. | Treat the message as human-readable text for your logs and support team, not as a stable machine-readable code. Branch on the status code instead. --- # Authentication Bob Go authenticates API traffic with a **long-lived API key** presented as a bearer token. There is no login call and no token to refresh: the key you create in the dashboard is the credential you send on every request. ## Getting a key Create an API key from the Bob Go dashboard under **Settings → API keys** ([Sandbox](https://sandbox.bobgo.co.za/settings?tab=api-keys)). The key is issued against a dedicated API user on your account, so: - it is scoped to that one account, so it cannot read or write another account's data; - it inherits that user's role and permissions; - revoking it in the dashboard takes effect immediately. Sandbox and Production are separate platforms with separate keys. A Sandbox key will not authenticate against Production. ## Sending the key Send it in the `Authorization` header on every request: ```http POST /v2/shipments HTTP/1.1 Host: api.bobgo.co.za Authorization: Bearer content-type: application/json ``` In this collection that is already wired up: the collection root sets bearer auth to `{{api_key}}` and every request uses `auth: inherit`. Set `api_key`, on the collection or on the environment you selected, and all requests are authenticated. > Keep keys out of version control. The committed collection ships an empty > `api_key` on purpose, so fill it in locally. The key also tells Bob Go which account you are acting on, so no request needs an `account_id`. If you send one anyway it is replaced with the key's account, not checked against it. ## Keeping the key safe The key is equivalent to a password on your account, and it does not expire on its own: - Never ship it in client-side code, a mobile app bundle or a public repository. Calls to Bob Go belong on your server. - Always call over HTTPS, so the key is not readable in transit. - Rotate it if it may have leaked: create the new key, deploy it, then revoke the old one. Revocation is immediate, so revoking first causes an outage. ## Channel-scoped keys Keys created for a **channel** integration (a marketplace or cart connected to Bob Go) are additionally bound to that channel's identifier. Those requests must also send: ```http bobgo-channel-identifier: your-store.example.com ``` Send the identifier exactly as the channel has it. Bob Go ignores a leading `https://` and a trailing slash, but otherwise the comparison is exact, including case. Keys not tied to a channel do not need this header. A channel key fixes `channel_id` the way any key fixes `account_id`: whatever you send is replaced with the key's channel, and `GET /v2/orders` only lists that channel's orders. ## Failures | Status | Message | Cause | | --- | --- | --- | | `401` | `Authentication failed, please check your API key.` | The key is not recognised, or has been revoked. | | `401` | `Authentication failed, please check your API key and headers.` | A channel key without a matching `bobgo-channel-identifier` header. | | `403` | `You're not logged in. Please log in and try again.` | No `Authorization` header, or one without the `Bearer ` prefix. | | `403` | `Upgrade to a higher plan to unlock this feature.` | The account's plan does not include API access. | | `403` | `You do not have access to the requested resource.` | The key's role may not call this endpoint. | --- # Getting started There are two ways in, and which one you want depends on whether Bob Go should know about your orders. **Ship directly.** Rate a parcel, book the rate you want, follow the shipment. You do not need an order in Bob Go first. **Push orders first.** Create the order in Bob Go, then fulfil it. You get the rest of the platform with it: packing slips, rates at checkout, bulk fulfilment, waybills, the order history in the dashboard. This is the route to take when you are integrating a sales channel Bob Go does not support natively. See [Orders](../6_Orders/README.md). Either way the shipping half is the same three calls: rates, shipment, waybill. ## 1. Set up 1. Create an API key in the Bob Go dashboard (see [Authentication](../2_Authentication/README.md)). The key decides which account every request acts on, so there is no account id to send. 2. Point at Sandbox (`https://api.sandbox.bobgo.co.za/v2`) while you build. In this Bruno collection, select the **Sandbox** environment and fill in `api_key`. Every request picks it up automatically. ## 2. Get rates `POST /v2/rates` takes a collection address, a delivery address and one or more parcels, and asks every courier enabled on your account what they would charge. The call is synchronous: it waits for the couriers to answer, up to `timeout` milliseconds. Couriers that fail or time out come back with a status on their own entry rather than failing the whole request, so always read `status` per provider before using a rate. **Send `timeout` on every call.** The maximum is 25 000 and the default is 0: leave it out and Bob Go checks once, after a fifth of a second, so most rates come back still `pending`. Send `25000` to wait as long as the API allows, which is about 20 seconds of polling once the response budget is deducted. Keep the `id` from the response. If you would rather not hold the connection open, pass a short `timeout` and fetch the rates afterwards with [`GET /v2/rates?id=…`](../8_Rates/rate_request.md). See [Rates](../8_Rates/rates.md). ## 3. Create the shipment `POST /v2/shipments` books the courier. You pick a rate from the previous response and pass its `provider_slug` and `service_level_code`, along with the same addresses and parcels and a contact name for each end. Only send a `provider_slug` and `service_level_code` that came back in a rate request. Not every courier serves every address, and booking a service level that is not available for the lane fails: the shipment is still created, then ends in `submission_status: "no-rates"`. Pass `selected_rate_request_id` (the `id` from the rates response) so Bob Go can tie the booking back to the quoted rate, and check the account has funds for that rate before it books anything. Bob Go submits the shipment to the courier asynchronously. The response returns immediately, and `submission_status` tells you where the submission has got to: | `submission_status` | Meaning | | --- | --- | | `pending-rates` | Queued, waiting for rates. | | `pending-submission` | Rated, waiting to be sent to the courier. | | `success` | The courier accepted the shipment. | | `no-rates` | No rate was available for the service level requested. | | `failed-will-retry` | The courier call failed; Bob Go will try again. | | `failed-indefinitely` | The submission failed for good. `failed_reason` says why. | Treat this as asynchronous state, not a return value. Subscribe to the `shipment_submission_status/updated` webhook rather than polling, and record the shipment `id` and `tracking_reference` as soon as you have them. See [Shipments](../9_Shipments/shipment_create.md). ## 4. Print the waybill Once `submission_status` is `success`, ask for the label from [`GET /v2/shipments/waybill`](../9_Shipments/shipment_waybill.md), or a sticker label from [`GET /v2/shipments/sticker-waybill`](../9_Shipments/shipment_sticker_waybill.md). Neither returns the file. You get a JSON envelope with a `download_url` to fetch, or, for a large batch or a ZPL sticker, instructions to poll or the label content inline. Check `waybills_ready` before assuming a label exists: a shipment whose submission has not landed yet has no waybill to print. Once the courier has accepted it, the shipment's `status` is `pending-collection`, or `awaiting-dropoff` if you hand it in at a pickup point, until the courier scans it. ## 5. Follow the shipment Poll [`GET /v2/tracking`](../10_Tracking%20events/tracking.md) for the shipment's events, or better, subscribe to [webhooks](../11_Webhooks/README.md) so Bob Go pushes them to you as they happen. If plans change, [`POST /v2/shipments/cancel`](../9_Shipments/shipment_cancel.md) cancels the booking, as long as the courier has not collected the parcel yet. ## Addresses that rate well Rating quality depends almost entirely on address quality. Send structured fields rather than one long string: ```json { "type": "residential", "company": "", "street_address": "5 Long Street", "local_area": "Cape Town City Centre", "city": "Cape Town", "zone": "Western Cape", "country": "ZA", "code": "8001" } ``` - `type` is `business`, `residential` or `locker`, and it affects both price and which service levels are available. Leave it out and it is `unknown`. - `zone` is the province; `code` is the postal code. - `country` must be `ZA` (or `South Africa`). Bob Go ships domestically. - Include `lat` / `lng` if you have them and skip our geocoding step. --- # Important concepts A few objects and ideas turn up across the whole API. Reading these once saves re-reading them on every endpoint. ## Addresses An address is always the same object, whatever key it appears under: `collection_address` and `delivery_address` have identical fields. ```json "collection_address": { "type": "business", "company": "Bob Go", "street_address": "125 Dallas Avenue", "local_area": "Menlyn", "city": "Pretoria", "zone": "Gauteng", "country": "ZA", "code": "0181" } ``` | Field | Type | Notes | | --- | --- | --- | | `type` | string | `business`, `residential`, `locker` or `unknown`. Defaults to `unknown`. Affects both price and which service levels are offered. | | `company` | string | Optional. | | `street_address` | string | Street number and name. | | `local_area` | string | Suburb. | | `city` | string | | | `zone` | string | Province, as its name (`Gauteng`) or its code (`GP`). Bob Go stores the code, so addresses come back with `EC`, `FS`, `GP`, `KZN`, `LP`, `MP`, `NC`, `NW` or `WC`, and filters on the province take the code. | | `code` | string | Postal code. | | `country` | string | `ZA` or `South Africa`. Bob Go ships within South Africa only, and an empty value is read as South Africa. Addresses come back with `South Africa`. | | `lat`, `lng` | number | Optional. Supply them and Bob Go skips geocoding. | Send the fields separately rather than packing everything into `street_address`. Rating and courier acceptance both depend on the address resolving to a real point on the map, and a single free-text line geocodes badly. ## Parcels Wherever a set of parcels is sent, it is the same array. ```json "parcels": [ { "description": "Box", "submitted_length_cm": 42.5, "submitted_width_cm": 38.5, "submitted_height_cm": 5.5, "submitted_weight_kg": 3, "custom_parcel_reference": "custom reference" }, { "description": "Flyer", "submitted_length_cm": 42.5, "submitted_width_cm": 38.5, "submitted_height_cm": 5.5, "submitted_weight_kg": 1.3 } ] ``` | Field | Type | Notes | | --- | --- | --- | | `submitted_length_cm` | number | Max 1000. | | `submitted_width_cm` | number | Max 1000. | | `submitted_height_cm` | number | Max 1000. | | `submitted_weight_kg` | number | A parcel over the maximum weight is rejected, and the message gives the limit. Each service level also has its own limit, on the total and the volumetric weight: a consignment over it fails that one rate with the limit in `failed_reason`, rather than failing the request. | | `description` | string | Optional. | | `custom_parcel_reference` | string | Optional, your own reference. | The `submitted_` prefix is deliberate: these are the dimensions *you* declared. Couriers re-measure, and the values they come back with land in the matching `actual_` fields on the shipment. Couriers charge on the greater of actual and volumetric weight, so dimensions matter as much as weight, and a parcel declared smaller than it is will be re-rated after collection. ## Rates at checkout vs. Rates Two endpoints return shipping prices, and they answer different questions. **[Rates at checkout](../5_Rates%20at%20checkout/rates_at_checkout.md)** (`POST /v2/rates-at-checkout`) returns the options *you have configured in Bob Go*: your service levels, your zones, your markups, your exclusions. It is what a buyer should see in a cart. Each option is priced the way you set it up, whether that is a dynamic, fixed, courier or formula rate, and it can deliberately return nothing for an area you do not ship to. **[Rates](../8_Rates/rates.md)** (`POST /v2/rates`) returns what each courier will cost you for a specific consignment, with none of your checkout rules applied. It is what you use to decide which courier to book. | | Rates at checkout | Rates | | --- | --- | --- | | Answers | "What should this buyer be charged?" | "What will each courier charge me?" | | Shaped by | Your Bob Go configuration | The couriers | | Typical caller | Your cart or checkout page | Your fulfilment code | | Returns | Priced options, buyer-facing | Per-courier quotes with a `rate_amount` | The two are not interchangeable. A buyer quoted a checkout rate is not being quoted the courier's price, and the difference between them is your shipping margin. ## Pickup points Shipments can collect from, or deliver to, a locker or counter instead of an address. Pickup points work through the same rates, checkout-rates and shipment endpoints, with a couple of extra fields. See [Pickup points](../12_Pickup%20points/locations.md). --- # Get checkout rates `POST /v2/rates-at-checkout` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.sandbox.bobgo.co.za/v2/rates-at-checkout' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "collection_address": { "type": "business", "company": "Bob Go", "street_address": "125 Dallas Avenue", "local_area": "Newlands", "city": "Pretoria", "zone": "Gauteng", "country": "ZA", "code": "0181" }, "delivery_address": { "type": "residential", "street_address": "5 Long Street", "local_area": "Cape Town City Centre", "city": "Cape Town", "zone": "Western Cape", "country": "ZA", "code": "8001" }, "items": [ { "description": "Coffee mug", "sku": "MUG-01", "price": 200, "quantity": 1, "length_cm": 17, "width_cm": 8, "height_cm": 5, "weight_kg": 0.4 } ], "order_total_price": 200, "handling_time": 2 }' ``` Returns the shipping options a buyer should be offered, priced by the rules you have configured in Bob Go. This is the cart endpoint, not for quoting a consignment you are about to book. For that, use [`POST /v2/rates`](../8_Rates/rates.md); the difference is explained in [Important concepts](../4_Important%20concepts/README.md#rates-at-checkout-vs-rates). What comes back is shaped entirely by your Bob Go setup: service levels you have enabled, the zones you serve, weight and order-value bands, markups and surcharges. Each service level is priced by the kind of rate you configured for it: | Configured as | How it is priced | | --- | --- | | Dynamic | Priced live off the courier's rate for the lane. | | Fixed | A flat price you set, regardless of the parcel. | | Courier | The courier's own price, passed through. | | Formula | Calculated from a formula you configure. | This is configuration only. The response does not say which kind priced an option; its `type` says how the parcel reaches the buyer. An area you have excluded returns no rate for that lane. When nothing matches, `rates` comes back as `null` or as an empty array, depending on where the match failed, so treat the two the same. That is a valid answer, not an error, and your checkout should handle it by hiding delivery rather than showing a price of zero. ## Request Three things are validated: `collection_address`, `delivery_address` and a non-empty `items` array. The account must also be active, or the call fails with `Account is not active.` Everything else is optional. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `collection_address` | object | yes | Where the goods ship from. See [address fields](../4_Important%20concepts/README.md#addresses). | | `delivery_address` | object | yes | The buyer's address. | | `items` | array | yes | The cart, at least one entry. See [item fields](#item-fields). | | `declared_value` | number | no | Total value of the cart in ZAR, drives liability cover. Defaults to `order_total_price` when you send it, otherwise to the item total. | | `handling_time` | integer | no | Business days between the order landing and the parcel being ready. Overrides the service level's own handling time, and shifts the delivery dates that come back. | | `channel_id` | integer | no | The Bob Go channel the cart belongs to. Defaults to your manual channel. A channel key always uses its own channel. | | `order_total_price` | number | no | Cart total after discounts and taxes. Use this to drive order-value rules. | | `delivery_type` | array of string | no | Only return some kinds of option: `door` keeps door delivery and in-store pickup, `locker` and `counter` keep pickup point options at locations of that type. Also accepts `pickup-point`, which keeps pickup point service levels but matches no location on its own. | ### Fields this endpoint ignores These parse without complaint and have no effect on the rates you get back. Do not build against them. | Field | Why | | --- | --- | | `total_weight_kg` | Always recalculated from `items`, using the greater of actual and volumetric weight. | | `total_price` | Always recalculated: from `order_total_price` if you sent it, otherwise from `items`. | | `currency` | Every rate comes back `ZAR` regardless. | | `channel_order_ref` | Not carried through, so it does not reach the rates-at-checkout history either. | | `collection_pickup_point_location_id` | Shipping **from** a pickup point is not wired up on this endpoint yet. Delivery **to** one works and needs no request field: pickup point options come back on their own. See [Pickup points](../12_Pickup%20points/locations.md). | | `collection_pickup_point_provider_slug` | As above. | | `customer_address` | Not used. The buyer's address is `delivery_address`. | | `store_location` | Set by Bob Go for in-store pickup, not by you. | | `rule_engine_fields` | Set by Bob Go while your checkout rules run, not by you. | ### Item fields None of these are enforced, but the quote is only as good as what you send: an item with no dimensions or no weight weakens the rate for the whole cart. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `description` | string | send it | Matched by rules that look at the description. | | `price` | number | send it | Unit price in ZAR. | | `length_cm` | number | send it | | | `width_cm` | number | send it | | | `height_cm` | number | send it | | | `weight_kg` | number | send it | An item left at `0` picks up the default item weight from your rates at checkout settings, if you have turned that on. | | `quantity` | integer | no | Defaults to `1`. An explicit `0` drops the item from dimension stacking, but it still counts once towards price and weight. | | `vendor` | string | no | | | `sku` | string | no | | | `shipping_class` | string | no | Matched by rules that price by shipping class. | Dimensions are per unit. Bob Go stacks them across the cart to work out the parcel it would take to ship the order. ## Response ```json { "rates": [ { "id": 1731, "service_name": "Economy", "service_code": "bobgo_1731_102_0", "total_price": 89.00, "base_rate": 79.00, "currency": "ZAR", "description": "Delivery in 3-5 business days", "min_delivery_date": "2026-09-09", "max_delivery_date": "2026-09-11", "type": "door", "service_level_priority": 1, "liability_cover": { "declared_value": 1500, "price": 10.00, "provider_liability_cover": false }, "surcharges": [] } ], "count": 1 } ``` | Field | Notes | | --- | --- | | `id` | The Bob Go rate the buyer picked. Keep it. `0` on the fallback rate. | | `service_name` | Display this to the buyer. | | `service_code` | Always prefixed `bobgo_`, and not stable: it ends with the option's position in this response (`_0`, `_1`, ...), so the same service can come back with a different code on the next call. Do not store it as a key. Send it back on the order as you received it, so the fulfilment side knows what was sold. | | `total_price` | What to charge the buyer, in ZAR, including liability cover and surcharges. | | `base_rate` | The price before liability cover and surcharges. | | `min_delivery_date` / `max_delivery_date` | Estimated window, already adjusted for `handling_time`. Null if you have turned delivery dates off in your checkout settings. | | `type` | How the parcel reaches the buyer: `door`, `pickup-point` or `in-store-pickup`. | | `service_level_priority` | The order you configured. Sort ascending to present them as intended. | | `liability_cover` | What the cover costs and whether the courier carries it. | | `surcharges` | Named amounts already included in `total_price`. Empty or null when none apply. | | `pickup_point_location_id` | Pickup point options only. Carry it through to the rate request and the shipment. | | `pickup_point_location_distance` | Pickup point options only: how far the location is from the delivery address, in km. | | `collection_pickup_point_location_id` | Present when one of your checkout rules ships this option from a pickup point. | | `in_store_pickup_location_distance` | In-store pickup options only: how far the store is from the buyer, in km. | | `provider_slug`, `service_level_code` | The courier and service level behind the rate. Present on pickup point rates, where you need them to book, and on door rates priced from a courier. | Rates are quotes for the buyer, not bookings. Nothing is reserved, and the amount you are charged when you book the shipment comes from the courier, not from here. --- # Orders An order is what your buyer placed. A shipment is how it gets to them. Pushing orders into Bob Go is optional, and you can book shipments without them, but it unlocks the rest of the platform: packing slips, bulk fulfilment, the order history in the dashboard, and rates at checkout tied to a real cart. Create orders here when you are integrating a sales channel Bob Go does not support natively. The `/v2/orders` endpoint validates lightly on purpose: send the detail you have, and fill in more as you get it. Lightly means almost not at all for an API key, so check [what you send](order_create.md), because a malformed order is accepted rather than rejected. ## Orders and fulfilments One order can be fulfilled more than once: a back-ordered item shipped later, a buyer collecting part of the order in store. Each of those is a **fulfilment** hanging off the order, and each has its own method (`shipment`, buyer collection) and its own status. That is why `fulfillment_status` on the order has three values, `unfulfilled`, `partially-fulfilled` and `fulfilled`, and why the shipment's tracking reference lives on the fulfilment rather than on the order. Subscribe to the `fulfillment/created` webhook to hear about each fulfilment as soon as it has been created successfully, apart from returns, which do not send it. See [Webhooks](../11_Webhooks/README.md). ## Endpoints | | | | --- | --- | | [`POST /v2/orders`](order_create.md) | Create an order. | | [`PATCH /v2/orders`](order_update.md) | Update one. | | [`GET /v2/orders`](order_list.md) | List and filter them. | | [`GET /v2/order-fulfillments`](order_fulfillments.md) | Read an order's fulfilments. | | [`POST /v2/orders/return`](order_return.md) | Book a return against an order. | --- # Create an order `POST /v2/orders` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.sandbox.bobgo.co.za/v2/orders' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "channel_order_number": "ABC123", "customer_name": "John", "customer_surname": "Doe", "customer_email": "john@doe.com", "customer_phone": "0123456789", "currency": "ZAR", "buyer_selected_shipping_cost": 49.99, "buyer_selected_shipping_method": "Standard Shipping", "delivery_address": { "company": "", "street_address": "171 Dallas Ave", "local_area": "Menlyn", "city": "Pretoria", "zone": "Gauteng", "country": "ZA", "code": "0181" }, "order_items": [ { "description": "item1", "vendor": "item1", "sku": "sku1", "unit_price": 100, "qty": 15, "unit_weight_kg": 0.1, "unit_length_cm": 20, "unit_width_cm": 15, "unit_height_cm": 5, "channel_image_url": "https://example.com/images/sku1.jpg" }, { "description": "item2", "vendor": "item2", "sku": "sku2", "unit_price": 200, "qty": 10, "unit_weight_kg": 0.1 } ], "payment_status": "paid" }' ``` Creates an order on your Bob Go account. **For an API key, only a non-empty `order_items` array is enforced**, plus `channel_order_number` when the order lands on an API channel. Every other validation failure is logged and ignored, and the order is created anyway. That is deliberate, so a plugin or cart integration never loses an order over a field it could not fill, but it means a malformed order comes back `200` rather than `400`. Check what you sent; the API will not. Send the rest regardless. The more you send, the more of the platform works without further calls: a delivery address makes the order rateable, order items make packing slips useful, and weights make the rate accurate. The "Required" column below is what the API rejects you for. Everything marked "send it" is optional to the API and needed in practice. ## Request ### Customer | Field | Type | Required | Notes | | --- | --- | --- | --- | | `customer_name` | string | send it | First name. | | `customer_surname` | string | send it | | | `customer_email` | string | send it | Used for tracking notifications. Send this or `customer_phone`. | | `customer_phone` | string | send it | Used for delivery notifications. Send this or `customer_email`. | ### Order | Field | Type | Required | Notes | | --- | --- | --- | --- | | `channel_order_number` | string | on an API channel | Your own order number. Shows in the dashboard and on documents. Required when the order lands on an API channel, which is always the case with a channel key. On any channel, a second order with the same number is rejected with `Order number already exists on channel.` Left out elsewhere, Bob Go generates one, e.g. `O-RC4R`. | | `currency` | string | no | Defaults to `ZAR` when omitted. | | `buyer_selected_shipping_cost` | number | send it | What the buyer paid for shipping. Must be `>= 0`. | | `buyer_selected_shipping_method` | string | send it | What they picked, e.g. `Standard Shipping`. Send the `service_name` from the checkout rate. | | `payment_status` | string | send it | `unpaid`, `pending`, `authorized`, `partially-paid`, `paid`, `partially-refunded`, `refunded` or `voided`. Comes back `unpaid` when you leave it out. Not checked for an API key, so send one of these exactly. | ### Delivery address Optional, and validated only if you send it: an order without one is created fine, but it cannot be rated or fulfilled until you add one with [`PATCH /v2/orders`](order_update.md). Send it if you have it. Same shape as everywhere else, see [address fields](../4_Important%20concepts/README.md#addresses). When you do send an address, `street_address`, `local_area`, `city`, `zone`, `country` and `code` all need to be there for it to geocode well. `company` is optional. ### Order items `order_items` itself is required and must hold at least one entry. The fields on each entry are not enforced for an API key, but the limits below are what the dashboard applies, and a value outside them will be rejected there. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `description` | string | send it | | | `unit_price` | number | send it | Per unit, in ZAR. Between 0 and 500000. | | `qty` | integer | send it | Between 0 and 2000. | | `unit_weight_kg` | number | send it | Per unit. Drives the rate when the order is fulfilled. Between 0 and 2000. | | `sku` | string | no | | | `vendor` | string | no | | | `unit_length_cm` | number | no | Per unit. Matched by rules that look at item or stacked dimensions, for example one that sets the parcels for large items. Send all three dimensions or none: an item missing any of them is left out of the stacked dimensions. | | `unit_width_cm` | number | no | As `unit_length_cm`. | | `unit_height_cm` | number | no | As `unit_length_cm`. | | `channel_image_url` | string | no | A public URL for the product image. See [item images](#item-images). | ### Item images Send `channel_image_url` on an item and Bob Go downloads the image after the order is created, keeps its own copy, and shows it against the item in the dashboard and on packing and picking slips. The URL must be publicly reachable and return the image with a `200`: Bob Go fetches it with a plain `GET` and no credentials. Each item comes back with `image_url`. That is the URL you sent until Bob Go's copy is ready, and the copy after that. If the download fails, it stays the URL you sent. ## Response The response is the created order, including the Bob Go `id`. Keep it, because it is what [`PATCH /v2/orders`](order_update.md) and [`GET /v2/order-fulfillments`](order_fulfillments.md) take. --- # Update an order `PATCH /v2/orders` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X PATCH 'https://api.sandbox.bobgo.co.za/v2/orders' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "id": 123, "payment_status": "paid" }' ``` Updates an existing order. Send the `id` and only the fields that changed; anything you leave out is left alone. The order lines are the exception, see `order_items` below. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `id` | integer | yes | The Bob Go order id, from the create response. An id that is not on your account returns `404`. | Everything else is optional and matches the fields on [`POST /v2/orders`](order_create.md), with these exceptions: - **`order_items` only applies to orders from your manual, CSV or API channel.** On an order from a store integration it is ignored. - **`order_items` replaces the whole list.** Lines you leave out are deleted and lines without an `id`, or with one that is not on this order, are added as new lines, so send every line you want to keep. You cannot remove every line. - **Send each line you keep in full.** Every field on it is overwritten, so a field you leave out is cleared: `unit_price` becomes 0, the image goes, and without `qty` the line counts as quantity 0, which deletes it, or fails if any of it is fulfilled. `description` is the exception: a line without one is rejected with a `400`. - **`channel_image_url` is not copied to Bob Go on update**, as it is on create: `image_url` serves the URL you send. - **`status`** can only be set to `completed` or `cancelled`. Other values are ignored. - **`buyer_selected_shipping_cost` and `buyer_selected_shipping_method`** only change on orders from your manual or CSV channel. A cancelled order cannot be changed. The common case is payment: an order created `unpaid` and patched to `paid` once the gateway settles. Rules that only act on paid orders will then pick it up. --- # Get orders `GET /v2/orders?limit=10&offset=0` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/orders?limit=10&offset=0' \ -H "Authorization: Bearer " ``` Lists orders on your account, most recently placed first. Standard [pagination](../1_Introduction/README.md#listing-and-pagination) applies: `limit`, `offset`, `order_by` and `order`. The response carries a `count` of the full result set. A channel key only sees the orders on its own channel, except when it fetches one order by `id`: that only checks the order is on your account. ## Parameters | Name | Type | Notes | | --- | --- | --- | | `id` | integer | Fetch one order. | | `channel_order_number` | string | Filter on your own order number. | | `limit` | integer | Page size. Defaults to 100, capped at 100. | | `offset` | integer | Rows to skip. Use with `limit` to page. | | `start_date` | timestamp | Filters on the date the order was placed. | | `end_date` | timestamp | Filters on the date the order was placed. | | `order` | string | `asc` or `desc`. Ignored unless you also send `order_by`. | | `order_by` | string | Field to sort on. Leave it out for the default, `date_placed_on_channel` descending. Sent without `order`, it sorts ascending. | | `payment_status` | array of string | `unpaid`, `pending`, `authorized`, `partially-paid`, `paid`, `partially-refunded`, `refunded`, `voided`. | | `status` | array of string | `new`, `processing`, `completed`, `cancelled`. A new order's `status` comes back empty, and `new` matches it. | | `fulfillment_status` | array of string | `fulfilled`, `unfulfilled`, `partially-fulfilled`. | | `tracking_reference` | string | The tracking reference of a shipment fulfilling the order. | | `has_delivery_address` | boolean | `true` returns only orders with a delivery address, `false` only orders without one. | | `delivery_address_zone` | array of string | Filter on the province code of the delivery address, e.g. `["GP","WC"]`. A province name such as `Gauteng` matches nothing. See [addresses](../4_Important%20concepts/README.md#addresses). | | `archived` | boolean | `true` returns only archived orders, `false` only orders that are not archived. Leave it out to get both. | | `include_items` | boolean | The order's line items, in `order_items`. Defaults to `true`. | | `include_relations` | boolean | The delivery address, channel, tags and the users who created or archived the order. Defaults to `true`. | | `include_fulfillments` | boolean | The fulfilments, with their tracking references. Defaults to `false`. | Array filters are sent as a JSON array in the query string, with no spaces, e.g. `?fulfillment_status=["unfulfilled","partially-fulfilled"]`. With a space after the comma the filter matches nothing. ## Response One order, trimmed to the fields most integrations read: ```json { "orders": [ { "id": 22837, "account_id": 1234, "channel_id": 311, "channel_order_number": "ABC123", "customer_name": "John", "customer_surname": "Doe", "customer_email": "john@doe.com", "customer_phone": "0123456789", "currency": "ZAR", "buyer_selected_shipping_cost": 49.99, "buyer_selected_shipping_method": "Standard Shipping", "payment_status": "paid", "status": "", "fulfillment_status": "unfulfilled", "total_price": 1500, "total_qty": 15, "total_weight_kg": 1.5, "date_placed_on_channel": "2026-09-05T08:30:00Z", "delivery_address": { "...": "..." }, "order_items": [ { "id": 31119, "description": "item1", "sku": "sku1", "vendor": "item1", "qty": 15, "fulfilled_qty": 0, "unit_price": 100, "unit_weight_kg": 0.1 } ], "time_created": "2026-09-05T08:30:12Z" } ], "count": 1 } ``` Items and relations are on unless you turn them off, so on a large page ask only for what you use. --- # Get order fulfillments `GET /v2/order-fulfillments?order_id=970` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/order-fulfillments?order_id=970' \ -H "Authorization: Bearer " ``` Returns every fulfilment on an order, one per shipment or buyer collection, newest first. There is no paging: `limit` and `offset` are ignored. This is where a shipment's tracking reference lives; the order itself does not carry one, because an order can be fulfilled more than once. ## Parameters | Name | Type | Required | Notes | | --- | --- | --- | --- | | `order_id` | integer | yes | The Bob Go order id. An order that is not on your account returns `404`. | | `id` | integer | no | Fetch one fulfilment. | | `include_collection_address` | boolean | no | Include the address the parcel is collected from. | | `include_return` | boolean | no | Include the return, if the fulfilment is one. | ## Response Each entry wraps the fulfilment alongside whatever is fulfilling it. Note the nested `order_fulfillment` key: ```json { "order_fulfillments": [ { "order_fulfillment": { "id": 6432, "account_id": 1234, "order_id": 22837, "method": "shipment", "method_id": 14273, "shipment_id": 14273, "method_reference": "UADPWTTF", "method_status": "pending-collection", "status": "success", "failed_reason": "", "type": "original", "source": "api", "time_created": "2022-10-20T19:42:00.642739Z", "items": [ { "id": 9921, "order_fulfillment_id": 6432, "order_id": 22837, "order_item_id": 31119, "qty": 1, "order_item": { "id": 31119, "description": "Product 1", "sku": "", "qty": 1, "fulfilled_qty": 1, "unit_price": 599, "unit_weight_kg": 0.1 } } ] }, "buyer_collection": null, "shipment": { "...": "..." } } ], "count": 1 } ``` | Field | Notes | | --- | --- | | `method` | How it is being fulfilled, e.g. `shipment`. | | `method_id` | The shipment or buyer-collection id. | | `method_reference` | The tracking reference. | | `method_status` | The shipment's tracking status, e.g. `pending-collection`. | | `status` | Whether the fulfilment itself was created successfully. | | `failed_reason` | Populated when `status` is a failure. | | `type` | `original`, `return` or `replacement`. | | `items` | The lines covered by this fulfilment. `qty` is how many of the line went out on this fulfilment, and the order line itself is nested under `order_item`. | | `shipment` | The full shipment, when `method` is `shipment`; otherwise null. | | `buyer_collection` | The buyer collection, when that is the method; otherwise null. | On `order_item`, `fulfilled_qty` against `qty` is the line's progress across every fulfilment so far, and is what makes an order partially fulfilled: a line shipped 3 of 5 leaves the order in `partially-fulfilled` until the rest goes out. > The `fulfillment/created` webhook delivers the fulfilment **unwrapped**, and > its `order_items` are flat order lines rather than the nested `items` above. > If you handle both, normalise them. See [Webhooks](../11_Webhooks/README.md). --- # Create a return `POST /v2/orders/return` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.sandbox.bobgo.co.za/v2/orders/return' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "order_id": 5531, "provider_slug": "demo", "service_level_code": "ECO", "collection_address": { "type": "residential", "street_address": "21 Harley Street", "local_area": "Ferndale", "city": "Randburg", "zone": "Gauteng", "country": "ZA", "code": "2194" }, "delivery_address": { "type": "business", "company": "Bob Go", "street_address": "126 Dallas Avenue", "local_area": "Waterkloof Glen", "city": "Pretoria", "zone": "Gauteng", "country": "ZA", "code": "0010" }, "parcels": [ { "description": "Standard sleeve", "submitted_length_cm": 42, "submitted_width_cm": 38, "submitted_height_cm": 5, "submitted_weight_kg": 1 } ], "declared_value": 350, "collection_contact_name": "John", "collection_contact_surname": "Doe", "collection_contact_email": "john@doe.com", "collection_contact_mobile_number": "0123456789", "delivery_contact_name": "Jane", "delivery_contact_surname": "Doe", "delivery_contact_email": "jane@doe.com", "delivery_contact_mobile_number": "0123456789", "instructions_collection": "Ring the bell at the gate" }' ``` Books a return shipment against an existing order: the courier collects from the buyer and delivers back to you. It is a shipment like any other, needing a rate, a courier and a service level, but it hangs off the order so the platform can tie the return to what was originally sold. Get rates for the return the same way you would for an outbound shipment, with [`POST /v2/rates`](../8_Rates/rates.md): the collection address is the buyer's and the delivery address is yours. ## Request Nothing is copied from the order: send both addresses, even though the collection address is usually the order's delivery address. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `order_id` | integer | yes | The order being returned against. | | `provider_slug` | string | yes | The courier, from the rate you selected. | | `service_level_code` | string | yes | The service level, from the rate you selected. | | `collection_address` | object | yes | Where the courier collects, usually the buyer. Not needed when you send `collection_pickup_point_location_id`. | | `delivery_address` | object | yes | Where the goods come back to. Not needed when you send `pickup_point_location_id`. | | `parcels` | array | yes | What is coming back. See [parcel fields](../4_Important%20concepts/README.md#parcels). | | `items` | array | no | The order lines being returned, as `{ "order_item_id": …, "qty": … }`. Each must be a line on this order, or the request is refused with `400`. Leave it out to return every line in full. | | `declared_value` | number | no | Value of the contents in ZAR. | | `selected_rate_request_id` | integer | no | The `id` from the rates response, for the funds check. | | `collection_min_date` | timestamp | no | Earliest date the courier may collect. | | `collection_after` / `collection_before` | string | no | Collection window, `hh:mm:ss`. | | `instructions_collection` | string | no | Note for the collection driver. | | `instructions_delivery` | string | no | Note for the delivery driver. | | `collection_contact_name` | string | no | Who the courier collects from. First name. | | `collection_contact_surname` | string | no | | | `collection_contact_mobile_number` | string | no | | | `collection_contact_email` | string | no | Checked when you send it. | | `delivery_contact_name` | string | no | Who receives it. First name. | | `delivery_contact_surname` | string | no | | | `delivery_contact_mobile_number` | string | no | | | `delivery_contact_email` | string | no | Checked when you send it. | | `custom_tracking_reference` | string | no | Your own reference. | | `shipping_label_emails` | array of string | no | Email the label to these addresses. | | `pickup_point_location_id` | integer | no | Return to a pickup point. | | `collection_pickup_point_location_id` | integer | no | Collect from a pickup point. | ## Response ```json { "order_fulfillment": { "...": "..." }, "shipment": { "...": "..." } } ``` The return is created as a fulfilment on the order, with the return shipment alongside it. `shipment.tracking_reference` is what you track, and `shipment.submission_status` tells you whether it reached the courier. It is asynchronous here exactly as it is on [`POST /v2/shipments`](../9_Shipments/shipment_create.md#statuses). A return whose shipment could not be created still comes back `200`. Check `order_fulfillment.status`: on `failed`, `order_fulfillment.failed_reason` says why. It usually comes back `pending` and can still fail afterwards, for example with `no-rates` on a service level that was not quoted for the lane, so read it again with [`GET /v2/order-fulfillments`](order_fulfillments.md). A return does not send the `fulfillment/created` webhook. Its shipment sends `shipment_submission_status/updated` and `tracking/updated` like any other shipment. See [Webhooks](../11_Webhooks/README.md). --- # Packages A package is a saved box: a name and its outer dimensions. Define the ones you actually pack into, and your team picks a package by name instead of typing dimensions on every shipment: fewer keystrokes, and far fewer re-rates caused by a mistyped size. Packages carry dimensions only, not weight. Weight varies with what goes in the box, so it stays on the parcel. One package on the account can be the default (`is_default`), which is the one pre-selected when a shipment is created in the dashboard. Setting a new default clears the old one, because only one is allowed. The default can be replaced but never removed, so an account that has one always has one. ## Endpoints | | | | --- | --- | | [`POST /v2/packages`](packages_create.md) | Create packages. | | [`POST /v2/packages`](packages_update.md) | Update packages, on the same endpoint. | | [`GET /v2/packages`](packages_get.md) | List them. | | [`DELETE /v2/packages`](packages_delete.md) | Delete one. | --- # Create packages `POST /v2/packages` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.sandbox.bobgo.co.za/v2/packages' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "packages": [ { "name": "Standard sleeves", "length_cm": 42, "height_cm": 5, "width_cm": 38 }, { "name": "Box", "length_cm": 42, "height_cm": 5, "width_cm": 38 } ] }' ``` Creates one or more packages. Send them as an array in `packages`, even when there is only one. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `packages` | array | yes | The packages to create. | | `packages[].name` | string | send it | What your team picks the package by. | | `packages[].length_cm` | number | yes | Greater than 0, at most 1000. | | `packages[].width_cm` | number | yes | Greater than 0, at most 1000. | | `packages[].height_cm` | number | yes | Greater than 0, at most 1000. | | `packages[].is_default` | boolean | no | Make this the account's default package. Only one entry may set it. | ## Response The packages you sent, as a **bare array** rather than wrapped in an object: ```json [ { "id": 2241, "account_id": 1234, "name": "Standard sleeve", "is_default": false, "length_cm": 42, "width_cm": 38, "height_cm": 5, "time_created": "2026-09-05T08:30:00Z", "time_modified": "2026-09-05T08:30:00Z" } ] ``` --- # Update packages `POST /v2/packages` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.sandbox.bobgo.co.za/v2/packages' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "packages": [ { "id": 2241, "name": "Standard sleeve updated", "is_default": true, "length_cm": 40, "height_cm": 5, "width_cm": 38 }, { "id": 2242, "name": "Box updated", "length_cm": 40, "height_cm": 5, "width_cm": 38 } ] }' ``` The same endpoint as [Create packages](packages_create.md). Include a package's `id` on an entry and that package is updated rather than created, so one call can mix new and changed packages. An `id` that does not exist is created as a new package rather than rejected. Send every field on each entry you update, not only the ones that changed: the entry replaces the package. **Changing the default package.** Any entry can take over as the default by setting `is_default: true`, and the old default is cleared. But if the current default is in the body and no entry sets `is_default: true`, the call fails with a `500` and `Cannot unset default package.`, even when all you changed was its name. Send `is_default: true` whenever you update the default package. The fields are the same as on [Create packages](packages_create.md), plus: | Field | Type | Required | Notes | | --- | --- | --- | --- | | `packages[].id` | integer | yes | The package to update. | The response is the same bare array as on create, holding only the packages you sent. --- # Get packages `GET /v2/packages` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/packages' \ -H "Authorization: Bearer " ``` Lists the packages on your account, the default package first and then by name. | Name | Type | Notes | | --- | --- | --- | | `id` | integer | Fetch one package. | | `limit` | integer | Page size. There is no default and no cap: leave it out and every package comes back. | | `offset`, `order`, `order_by` | | Standard [pagination](../1_Introduction/README.md#listing-and-pagination). | ## Response ```json { "packages": [ { "id": 2241, "account_id": 1234, "name": "Standard sleeve", "is_default": true, "length_cm": 42, "width_cm": 38, "height_cm": 5 }, { "id": 2242, "account_id": 1234, "name": "Box", "is_default": false, "length_cm": 42, "width_cm": 38, "height_cm": 5 } ], "count": 2 } ``` `count` is every package that matches, not the size of the page. --- # Delete a package `DELETE /v2/packages` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X DELETE 'https://api.sandbox.bobgo.co.za/v2/packages' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "id": 17 }' ``` Deletes one package, given its `id` in the JSON body. The id cannot go in the query string. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `id` | integer | yes | The package to delete. | A successful delete returns `204` with no body. The delete is soft: shipments already created with those dimensions are untouched, and the package simply stops being offered. Two packages cannot be deleted: | Message | When | | --- | --- | | `Cannot delete last remaining package.` | It is the only package on the account. | | `Cannot delete a default package.` | It is the default. Make another package the default first, with [Update packages](packages_update.md). | An `id` that is not on your account fails with `Package does not belong to account.` --- # Rates Ask the couriers what a consignment will cost you to ship. The markup on your Bob Go plan is included; none of the checkout rules, rates-at-checkout markups or exclusions you configured in Bob Go are applied. For buyer-facing prices use [Rates at checkout](../5_Rates%20at%20checkout/rates_at_checkout.md); the difference is explained in [Important concepts](../4_Important%20concepts/README.md#rates-at-checkout-vs-rates). Rating is the step before booking. A shipment on a service level the couriers did not offer for that lane is still created, and then fails with `submission_status: "no-rates"` once Bob Go rates it, so rate first even when you already know which courier you want. | | | | --- | --- | | [`POST /v2/rates`](rates.md) | Quote a consignment across your couriers. | | [`GET /v2/rates`](rate_request.md) | Read the rates on a rate request you already made. | --- # Get rates `POST /v2/rates` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.sandbox.bobgo.co.za/v2/rates' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "collection_address": { "type": "business", "company": "Bob Go", "street_address": "24 Rothesay Avenue", "local_area": "Craighall Park", "city": "Johannesburg", "zone": "Gauteng", "country": "ZA", "code": "2196" }, "delivery_address": { "type": "residential", "street_address": "5 Long Street", "local_area": "Cape Town City Centre", "city": "Cape Town", "zone": "Western Cape", "country": "ZA", "code": "8001" }, "parcels": [ { "submitted_length_cm": 30, "submitted_width_cm": 20, "submitted_height_cm": 10, "submitted_weight_kg": 2, "description": "Box of books" } ], "declared_value": 1500, "timeout": 25000, "collection_contact_full_name": "Jane Seller", "collection_contact_mobile_number": "0821234567", "collection_contact_email": "seller@example.com", "delivery_contact_full_name": "John Buyer", "delivery_contact_mobile_number": "0839876543", "delivery_contact_email": "buyer@example.com" }' ``` Asks every courier enabled on your account to quote for a set of parcels moving between two addresses, and returns their answers in one response. The call is synchronous: Bob Go fans the request out to the couriers and waits for them, up to `timeout` milliseconds. A courier that fails or times out is reported on its own entry and does not fail the request. > **Always send `timeout` explicitly.** Omit it and Bob Go checks once, after > about 200 ms, so the response comes back with most rates still `pending`. > Send `25000` for the full wait. See below. ## Request | Field | Type | Required | Notes | | --- | --- | --- | --- | | `collection_address` | object | yes | Where the parcels are collected. See [address fields](../4_Important%20concepts/README.md#addresses). Not needed when you send `collection_pickup_point_location_id`. | | `delivery_address` | object | yes | Where the parcels are delivered. Not needed when you send `delivery_pickup_point_location_id`. | | `parcels` | array | yes | At least one parcel. See [parcel fields](../4_Important%20concepts/README.md#parcels). | | `declared_value` | number | no | Value of the contents in ZAR. Drives insurance and, for some couriers, the rate. | | `providers` | array of string | no | Restrict the quote to these provider slugs. Defaults to every provider enabled on the account. | | `service_levels` | array of string | no | Restrict the quote to these service level codes. | | `timeout` | integer | **send it** | Milliseconds to wait for couriers, capped at `25000`. Defaults to `0`, which means a single check after about 200 ms, not the maximum. | | `requested_collection_date` | timestamp | no | The date you intend to hand the parcels over. Affects couriers that price by lead time. | | `collection_contact_full_name` | string | no | | | `collection_contact_mobile_number` | string | no | | | `collection_contact_email` | string | no | | | `delivery_contact_full_name` | string | no | | | `delivery_contact_mobile_number` | string | no | | | `delivery_contact_email` | string | no | | | `collection_pickup_point_location_id` | integer | no | Collect from a pickup point instead of an address. Needs `pickup_point_provider_slug`. | | `delivery_pickup_point_location_id` | integer | no | Deliver to a pickup point / locker instead of an address. | | `pickup_point_provider_slug` | string | with a collection pickup point | The provider the pickup points belong to. Required with `collection_pickup_point_location_id`, and send it with `delivery_pickup_point_location_id` too. It restricts the quote to that provider, overriding `providers`, and it is what makes Bob Go check that the location is available and the parcels fit. Without it a delivery pickup point is not checked at all. See [Pickup points](../12_Pickup%20points/locations.md). | Couriers charge on the greater of actual and volumetric weight, so dimensions matter as much as weight. ## Response ```json { "id": 90881234, "account_id": 1234, "collection_address": { "...": "..." }, "delivery_address": { "...": "..." }, "parcels": [ { "submitted_length_cm": 30, "submitted_width_cm": 20, "submitted_height_cm": 10, "submitted_weight_kg": 2 } ], "declared_value": 1500, "total_submitted_weight_kg": 2, "timeout": 25000, "time_created": "2026-09-05T08:30:00Z", "provider_rate_requests": [ { "rate_response_id": 55512345, "provider_id": 4, "provider_slug": "tcg", "provider_name": "The Courier Guy", "status": "success", "failed_reason": "", "responses": [ { "service_level_code": "ECO", "service_level": { "...": "..." }, "rate_amount": 96.45, "rate_amount_excl_vat": 83.87, "charged_weight_kg": 2, "status": "success" } ] } ] } ``` Keep the top-level `id`: that is the `selected_rate_request_id` you pass when you create the shipment. ### How long the call actually waits `timeout` is the budget for the whole request, and Bob Go reserves the last five seconds of it to build and return the response. So `timeout: 25000` polls the couriers for roughly 20 seconds, not 25, and stops early once every courier has answered. Anything above `25000`, or below `0`, is clamped to `25000`. Omitting `timeout` is not the same as sending the maximum. Anything up to 5000 leaves no polling budget, so Bob Go checks once after about 200 ms and returns a `200` with whatever has come back, usually most providers still `pending`. Either send a real timeout, or send a short one and poll [`GET /v2/rates?id=…`](rate_request.md). ### Rate statuses `status` appears both per provider and per rate: | Status | Meaning | | --- | --- | | `success` | The rate is bookable. | | `success-no-rates` | The courier answered but does not service this lane. | | `pending` | Still waiting when the timeout elapsed. | | `failed-will-retry` | A transient failure at the courier. | | `failed-indefinitely` | The courier rejected the request, or Bob Go did: the parcels are over the service level's weight or size limit, or your balance does not cover the rate. `failed_reason` says why. | | `unimplemented` | The courier does not support this kind of quote. | Only offer rates with `status: "success"` to your customers. ## Notes - Rates are quotes, not reservations. Prices can move between quoting and booking; the amount actually charged is the one on the shipment. - `rate_amount` is what Bob Go will charge you for the shipment, VAT included, and `rate_amount_excl_vat` is the same price without VAT. Both include the markup on your Bob Go plan, unless the service level is set to exclude it. None of your rates-at-checkout markups or checkout rules apply here. - On a courier account connected with your own credentials, Bob Go adds nothing: both amounts are the courier's price as the courier quotes it, so they are equal. - A rate request with no usable rates is a `200`, not an error, so check the statuses. --- # Get an existing rate request `GET /v2/rates?id=340099` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/rates?id=340099' \ -H "Authorization: Bearer " ``` Returns a rate request you already made, with whatever the couriers have answered by now. Leave out `id` and you get the account's most recent rate requests instead. `POST /v2/rates` blocks until the couriers reply or `timeout` elapses, which is fine for a server-side call and awkward for anything user-facing. The alternative is to post with a short timeout, keep the `id` from the response, and poll here. Providers still marked `pending` when the timeout elapsed will have finished by the time you come back. ## Parameters | Name | Type | Required | Notes | | --- | --- | --- | --- | | `id` | integer | send it | The rate request `id` from the `POST /v2/rates` response. | | `limit` | integer | no | Page size when listing without an `id`. Defaults to 10. | | `offset`, `order`, `order_by` | | no | Standard pagination when listing. The default order is newest first. | ## Response **This is a list endpoint, so the shape differs from `POST /v2/rates`.** The rate request is wrapped in an array with a `count`, even when you filtered to one `id`: ```json { "rate_requests": [ { "id": 340099, "account_id": 1234, "collection_address": { "...": "..." }, "delivery_address": { "...": "..." }, "parcels": [{ "...": "..." }], "provider_rate_requests": [{ "...": "..." }] } ], "count": 1 } ``` Each element of `rate_requests` is the same object `POST /v2/rates` returns: see [Get rates](rates.md#response) for the fields, including the per-provider and per-rate `status` values you must read before using a rate. A request that came back with no usable rates is still a `200`. --- # Shipments A shipment is a booking with a courier: parcels, two addresses, a service level and a price. Creating one charges your Bob Go account and puts the consignment in front of the courier. **Rate before you book.** Which couriers and service levels are available depends on the addresses and the parcels: a service level that works for a Johannesburg business address may not exist for a residential address in a small town. A `provider_slug` or `service_level_code` Bob Go does not know, or a courier not enabled on your account, is a `400` straight away. A real service level that did not come back in a rate request is accepted, and the shipment then fails with `submission_status: "no-rates"` once Bob Go rates it. See [Rates](../8_Rates/README.md). **Submission is asynchronous.** The response arrives before the courier has seen the shipment. `submission_status` moves from `pending-rates` through `pending-submission` to `success`, or to a failure Bob Go may retry on its own. `status` is empty until then, and afterwards tracks the parcel itself, from `pending-collection` through to `delivered`. Both lists are in [Create a shipment](shipment_create.md#statuses). Build for that: read `submission_status` rather than assuming the booking worked, and subscribe to the `shipment_submission_status/updated` and `tracking/updated` webhooks instead of polling. See [Webhooks](../11_Webhooks/README.md). Common causes of `failed-indefinitely` are missing or invalid address detail, insufficient funds once the shipment is rated, and a problem at the courier. `failed_reason` says which. ## Endpoints | | | | --- | --- | | [`POST /v2/shipments`](shipment_create.md) | Book a courier. | | [`GET /v2/shipments`](shipment_list.md) | List and filter shipments. | | [`POST /v2/shipments/cancel`](shipment_cancel.md) | Cancel a booking. | | [`GET /v2/shipments/waybill`](shipment_waybill.md) | A4/A5/A6 waybill. Returns a link to the PDF. | | [`GET /v2/shipments/sticker-waybill`](shipment_sticker_waybill.md) | Sticker label, as a link to a PDF or ZPL inline. | | [`GET /v2/shipments/pod`](shipment_pod.md) | Proof of delivery. | | [`GET /v2/file-history`](file_history.md) | Collect a label batch that was queued rather than returned inline. | --- # Create a shipment `POST /v2/shipments` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.sandbox.bobgo.co.za/v2/shipments' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "provider_slug": "tcg", "service_level_code": "ECO", "custom_order_number": "ORD-10045", "collection_address": { "type": "business", "company": "Bob Go", "street_address": "24 Rothesay Avenue", "local_area": "Craighall Park", "city": "Johannesburg", "zone": "Gauteng", "country": "ZA", "code": "2196" }, "delivery_address": { "type": "residential", "street_address": "5 Long Street", "local_area": "Cape Town City Centre", "city": "Cape Town", "zone": "Western Cape", "country": "ZA", "code": "8001" }, "parcels": [ { "submitted_length_cm": 30, "submitted_width_cm": 20, "submitted_height_cm": 10, "submitted_weight_kg": 2, "description": "Box of books" } ], "declared_value": 1500, "collection_contact_name": "Jane", "collection_contact_surname": "Seller", "collection_contact_mobile_number": "0821234567", "collection_contact_email": "seller@example.com", "delivery_contact_name": "John", "delivery_contact_surname": "Buyer", "delivery_contact_mobile_number": "0839876543", "delivery_contact_email": "buyer@example.com", "instructions_collection": "Ring the bell at reception", "instructions_delivery": "Leave with the concierge" }' ``` Books a courier for a set of parcels. You choose the courier and service level, usually one of the rates returned by [`POST /v2/rates`](../8_Rates/rates.md), and Bob Go creates the shipment, charges your account and submits it to the courier. The response returns as soon as the shipment exists, unless you ask it to wait with `timeout`. Rating and submission to the courier happen asynchronously, so the price and the courier's own waybill number appear on the shipment a short while after it is created. ## Request | Field | Type | Required | Notes | | --- | --- | --- | --- | | `provider_slug` | string | yes | The courier, e.g. `tcg`. Take it from the rate you selected. | | `service_level_code` | string | yes | The service level, e.g. `ECO`. Take it from the rate you selected. | | `collection_address` | object | yes | Same shape as on `POST /v2/rates`. Not needed when you send `collection_pickup_point_location_id`. | | `delivery_address` | object | yes | Not needed when you send `delivery_pickup_point_location_id`. | | `parcels` | array | yes | At least one parcel. | | `collection_contact_name` | string | yes | First name. | | `delivery_contact_name` | string | yes | First name. | | `selected_rate_request_id` | integer | no | The `id` from the rates response. Send it so the quoted rate is used for the funds check. | | `declared_value` | number | no | Value of the contents in ZAR. | | `custom_order_number` | string | no | Your own order number. Shows on the shipment and on labels, and comes back as `meta.custom_order_number`. | | `custom_tracking_reference` | string | no | Your own tracking reference. | | `collection_min_date` | timestamp | no | Earliest date the courier may collect. Leave it out, or send a date in the past, for the earliest possible. Bob Go moves it to the next business day, and past the service level's cut-off time. | | `collection_after` / `collection_before` | string | no | Collection window on the collection date, `hh:mm:ss` (`hh:mm` is padded), e.g. `09:00:00` / `17:00:00`. Send both or neither, with the hour zero-padded, between `08:00:00` and `17:00:00`. Defaults to the whole of that window. | | `instructions_collection` | string | no | Free-text note for the collection driver. | | `instructions_delivery` | string | no | Free-text note for the delivery driver. | | `shipping_label_emails` | array of string | no | Email the label to these addresses once it is generated. | | `collection_contact_surname` | string | no | | | `collection_contact_mobile_number` | string | no | | | `collection_contact_email` | string | no | Checked when you send it. | | `delivery_contact_surname` | string | no | | | `delivery_contact_mobile_number` | string | no | Used for delivery notifications, so worth sending. | | `delivery_contact_email` | string | no | Used for tracking emails. Checked when you send it. | | `collection_pickup_point_location_id` | integer | no | Collect from a pickup point instead of an address. | | `delivery_pickup_point_location_id` | integer | no | Deliver to a pickup point / locker instead of an address. Ignored on a door-to-door service level. | | `timeout` | integer | no | Milliseconds to wait for the submission to settle before responding, capped at `25000`. As on `POST /v2/rates`, the last five seconds are reserved, so anything up to 5000 does not wait. Defaults to `0`. | Contact fields take a first name and surname here. `POST /v2/rates` takes a single `*_full_name`, but also accepts `*_contact_name` and `*_contact_surname` and joins them, so you can send the same contact fields to both. ## Response ```json { "id": 4455123, "account_id": 1234, "provider_slug": "tcg", "service_level_code": "ECO", "status": "", "submission_status": "pending-rates", "tracking_reference": "UATCG4X7Q2", "provider_tracking_reference": null, "declared_value": 1500, "meta": { "custom_order_number": "ORD-10045" }, "collection_address": { "...": "..." }, "delivery_address": { "...": "..." }, "parcels": [{ "...": "..." }], "time_created": "2026-09-05T08:31:12Z" } ``` A new shipment starts with `submission_status: "pending-rates"` and an empty `status`. Unless you sent a long enough `timeout`, that is what the response shows. `charged_amount` and `charged_weight_kg` fill in once Bob Go has rated the shipment. Store `tracking_reference`: it is Bob Go's identifier for the shipment and what you use to look it up, track it and match webhook events. `provider_tracking_reference` is the courier's own waybill number and is filled in once the courier accepts the submission. ## Statuses `submission_status` tracks getting the shipment to the courier: | Status | Meaning | | --- | --- | | `pending-rates` | Created, waiting on the courier's rate. | | `pending-submission` | Rated, queued for the courier. | | `success` | The courier accepted the shipment. | | `no-rates` | No bookable rate for this lane. | | `failed-will-retry` | Transient failure. Bob Go will retry. | | `failed-indefinitely` | Failed for good; `failed_reason` says why. | `status` tracks the parcel in the real world. It is empty until the courier accepts the shipment, then one of `pending-collection`, `awaiting-dropoff`, `collected`, `in-transit`, `out-for-delivery`, `ready-for-pickup`, `delivered`, `finalised`, `returned-to-sender`, `collection-exception`, `delivery-exception`, `failed-collection`, `failed-delivery`, `cancelled`, `cancelled-by-courier`. ## Notes - Creating a shipment charges your Bob Go account once it has been rated. Before that, Bob Go checks your funds: against the quoted rate when you send `selected_rate_request_id`, and against your balance and credit limit either way. Either check fails with a `400` saying the account has insufficient funds. - Funds are checked again when the courier's rate arrives. A shipment that fails there ends `failed-indefinitely`, with the insufficient-funds message as `failed_reason`. - Shipments on a courier account connected with your own credentials skip the funds checks. - Rates can move between quoting and booking. `charged_amount` on the shipment is authoritative, not the earlier quote. --- # Get shipments `GET /v2/shipments?offset=0&limit=10` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/shipments?offset=0&limit=10' \ -H "Authorization: Bearer " ``` Lists shipments on your account, newest first. Standard [pagination](../1_Introduction/README.md#listing-and-pagination) applies, and the response carries a `count` of the full result set. **Without a filter an API key only sees this month.** Unless you send an id, a reference or a date (`id`, `tracking_reference`, `start_date` or `end_date`, or one of a few less common filters such as `ids` and `provider_tracking_reference`), you get the shipments created since the 1st of the current month (South African time), not the whole history. On the 1st that is a single day. To reach further back, send a date range. ## Parameters | Name | Type | Notes | | --- | --- | --- | | `id` | integer | Fetch one shipment. | | `tracking_reference` | string | Fetch by Bob Go's tracking reference. | | `limit` | integer | Page size. Defaults to 100, capped at 100. | | `offset` | integer | Rows to skip. | | `start_date` | timestamp | Filters on the date the shipment was created. Send `end_date` with it: on its own it covers the seven days that follow. | | `end_date` | timestamp | Filters on the date the shipment was created. On its own it covers the seven days before. | | `order` | string | `asc` or `desc`. Ignored unless you also send `order_by`. | | `order_by` | string | Field to sort on. Leave it out for the default, `time_created` descending. Sent without `order`, it sorts ascending. | | `status` | array of string | The tracking status, e.g. `["in-transit"]`. | | `submission_status` | array of string | e.g. `["failed-indefinitely"]`. | | `provider_slug` | array of string | The courier. | | `service_level_code` | string | One service level, e.g. `ECO`. | | `service_level_codes` | array of string | Several service levels. | | `archived` | boolean | `true` returns only archived shipments. | Array filters are sent as a JSON array in the query string, with no spaces, e.g. `?status=["in-transit","out-for-delivery"]`. With a space after the comma the filter matches nothing. ## Response One shipment, trimmed to the fields most integrations read: ```json { "shipments": [ { "id": 4455123, "account_id": 1234, "tracking_reference": "UATCG4X7Q2", "custom_tracking_reference": null, "provider_slug": "tcg", "provider_tracking_reference": "TCG1234567", "service_level_code": "ECO", "status": "in-transit", "submission_status": "success", "failed_reason": null, "charged_amount": 96.45, "charged_weight_kg": 2, "submitted_weight_kg": 2, "declared_value": 1500, "parcel_count": 1, "collection_address": { "...": "..." }, "delivery_address": { "...": "..." }, "parcels": [{ "...": "..." }], "meta": { "custom_order_number": "ORD-10045" }, "time_created": "2026-09-05T08:31:12Z" } ], "count": 1 } ``` Filtering on `submission_status=["failed-indefinitely"]` is the cheap way to find bookings that never reached a courier, and worth a scheduled sweep if you are not consuming the submission-status webhook. --- # Cancel a shipment `POST /v2/shipments/cancel` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.sandbox.bobgo.co.za/v2/shipments/cancel' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "tracking_reference": "UASDRW78" }' ``` Cancels a shipment and refunds the charge to your Bob Go account. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `tracking_reference` | string | yes | Bob Go's tracking reference for the shipment. Your `custom_tracking_reference` is not accepted here. | | `note` | string | no | Free-text reason. It becomes the message on the shipment's `cancelled` tracking event; it is not kept on the shipment itself. | Bob Go decides there and then, from how far the shipment has got. A shipment can be cancelled while its `status` is empty or one of `pending-collection`, `awaiting-dropoff`, `failed-collection`, `collection-exception` or `cancelled-by-courier`. For a shipment collected from a pickup point, only an empty `status`, `awaiting-dropoff` or `cancelled-by-courier` qualifies. Anything further along is refused with a `400`: | Message | When | | --- | --- | | `The shipment cannot be cancelled anymore.` | The courier accepted it and it has moved past the statuses above. | | `The shipment cannot be cancelled, as it has not been submitted successfully.` | Submission failed for good (`failed-indefinitely` or `no-rates`). | ## Response On success the response is the whole shipment, with `status: "cancelled"`, and the charge is reversed. Bob Go then cancels the booking with the courier in the background, so there is nothing further to call. --- # Get a waybill `GET /v2/shipments/waybill?tracking_references=["UASDKRRB"]` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -g -X GET 'https://api.sandbox.bobgo.co.za/v2/shipments/waybill?tracking_references=["UASDKRRB"]' \ -H "Authorization: Bearer " ``` Generates the waybill PDF for one or more shipments and returns **a JSON envelope with a link to it**, not the file itself. ## Parameters | Name | Type | Required | Notes | | --- | --- | --- | --- | | `tracking_references` | array of string | one of these | The shipments to print. Sent as a JSON array in the query string, with no spaces: `["UASDKRRB","UASDKRRC"]`. | | `ids` | array of integer | one of these | Shipment ids. Sent with `tracking_references`, only shipments matching both are printed. | | `paper_size` | string | no | `A4`, `A5` or `A6`: the sheet you are printing on. Defaults to your account's waybill setting. | | `waybill_size` | string | no | `A5` or `A6`: the waybill itself on that sheet. It cannot be larger than `paper_size`. `A4` is accepted but printed as `A5`. Defaults to your account's waybill setting. | | `show_email_address` | boolean | no | Has no effect. Whether the delivery contact's email is printed is an account setting. | ``` GET /v2/shipments/waybill?tracking_references=["UASDKRRB"]&paper_size=A4&waybill_size=A5 ``` Cancelled shipments, and references that are not on your account, are skipped. If that leaves nothing to print, the call fails with `No shipments to generate a waybill for.` ## Response ```json { "pdf_queued": false, "file_history_id": null, "download_url": "https://...", "waybills_ready": true, "shipments": [ { "id": 4455123, "tracking_reference": "UASDKRRB", "submission_status": "success", "failed_reason": null } ] } ``` Three outcomes, and you have to branch on them: | `waybills_ready` | `pdf_queued` | What happened | | --- | --- | --- | | `true` | `false` | Generated inline. `download_url` is the PDF, fetch it. The link works for 24 hours. | | `true` | `true` | Queued for batch generation. `download_url` is null; poll with `file_history_id`. | | `false` | `false` | Nothing was generated, at least one shipment is not printable yet. | **Inline generation only ever handles one shipment.** Ask for two or more shipments, or for a single shipment with more than 20 parcels, and the request is always queued. There is no size at which a multi-shipment request comes back inline, so if you are printing a batch, write the polling path first: it is the normal case, not the exception. Collect the finished file with [`GET /v2/file-history`](file_history.md). The third row is the one to handle properly. A waybill only exists once the courier has accepted the shipment, so a shipment still submitting, or one that failed, produces no label. `shipments[]` tells you which: read `submission_status` and `failed_reason` per shipment rather than treating the empty `download_url` as an error. --- # Get a sticker waybill `GET /v2/shipments/sticker-waybill?tracking_references=["UASDRW78"]&type=pdf` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -g -X GET 'https://api.sandbox.bobgo.co.za/v2/shipments/sticker-waybill?tracking_references=["UASDRW78"]&type=pdf' \ -H "Authorization: Bearer " ``` Produces a label sized for a sticker printer, rather than the A4/A5 waybill from [`GET /v2/shipments/waybill`](shipment_waybill.md). The `type` parameter decides both the format **and the shape of the response**. Sticker labels are a paid feature, per format. An account without it gets `Upgrade to a higher plan to unlock this feature.` with a `400`, and no label. ## Parameters | Name | Type | Required | Notes | | --- | --- | --- | --- | | `tracking_references` | array of string | yes | The shipments to print. Sent as a JSON array in the query string, with no spaces. Unlike the waybill endpoint, `ids` cannot replace it. | | `ids` | array of integer | no | Shipment ids. Only narrows `tracking_references`: shipments must match both. | | `type` | string | no | `zpl` or `pdf`. Defaults to `zpl`. | ``` GET /v2/shipments/sticker-waybill?tracking_references=["UASDRW78"]&type=zpl ``` ## Response for `type=zpl` The ZPL comes back **inline, one label per parcel**, in `sticker_waybill_content`. Send each string straight to a Zebra printer; there is no file and no `download_url`. ```json { "pdf_queued": false, "waybills_ready": true, "download_url": null, "file_history_id": null, "shipments": [ { "id": 4455123, "tracking_reference": "UASDRW78", "submission_status": "success", "failed_reason": null, "parcels": [ { "parcel_reference": "UASDRW78/1", "sticker_waybill_content": "^XA^FO..." } ] } ] } ``` A two-parcel shipment gives you two `parcels` entries and two labels to print. `parcel_reference` is the parcel's own Bob Go tracking reference. ## Response for `type=pdf` Same envelope as [`GET /v2/shipments/waybill`](shipment_waybill.md#response), with the same three outcomes: generated inline with a `download_url`, queued for batch generation with a `file_history_id`, or not generated at all because a shipment is not printable yet. In both cases `shipments[]` carries `submission_status` and `failed_reason`, so check those before assuming a label exists. --- # Get a proof of delivery `GET /v2/shipments/pod?tracking_reference=UASDRW78` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/shipments/pod?tracking_reference=UASDRW78' \ -H "Authorization: Bearer " ``` Returns the courier's proof of delivery for a shipment, typically a signature or a delivery photograph. | Name | Type | Required | Notes | | --- | --- | --- | --- | | `tracking_reference` | string | yes | Bob Go's tracking reference for the shipment. | ## Response ```json { "pods": [ { "data": "", "extension": ".pdf", "url": "https://..." } ], "count": 1 } ``` | Field | Notes | | --- | --- | | `data` | The document itself, base64-encoded, from the few couriers that send the file. Empty otherwise. | | `extension` | What it is, with a leading dot: `.pdf`, `.png` or `.jpg`. Depends on the courier. | | `url` | A link to the document. Most couriers provide this rather than `data`. | A shipment can have more than one POD, so `pods` is an array; `count` is how many there are. A POD only exists once the courier has delivered the parcel **and** uploaded it, which can lag delivery by a day or more, and not every courier supplies one. An empty `pods` array is normal rather than an error. Retry later, and don't block your flow on it. A tracking reference that does not exist, or is not on your account, comes back as a `404` with the same message either way, so the response never confirms that another account's reference exists. A failure on our side, such as a database error, is a `500`. When the courier's own lookup fails, its status is passed on, so some couriers answer a shipment with no POD yet with a `400` instead of an empty `pods` array. Read the message before retrying. --- # Poll a queued label batch `GET /v2/file-history?id=88213` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/file-history?id=88213' \ -H "Authorization: Bearer " ``` Reads one generated-file record. You need this when [`GET /v2/shipments/waybill`](shipment_waybill.md) or [`GET /v2/shipments/sticker-waybill`](shipment_sticker_waybill.md) answers with `pdf_queued: true`: the PDF is being built in the background and this is where the finished file appears. Any request for more than one shipment is queued, so on a batch print this is part of the normal path rather than an edge case. ## Parameters | Name | Type | Required | Notes | | --- | --- | --- | --- | | `id` | integer | yes | The `file_history_id` from the label response. An id that is not on your account fails with `No file history found.` | ## Response ```json { "file_history": [ { "id": 88213, "account_id": 1234, "file_type": "pdf-waybills", "file_status": "success", "file_url": "https://...", "failed_reason": null, "time_created": "2026-09-05T08:31:12Z", "metadata": { "object_reference": null, "object_id": null, "sent_by_email": false, "has_multiple": true } } ], "count": 1 } ``` | Field | Notes | | --- | --- | | `file_status` | `processing`, `success` or `failed`. | | `file_url` | The finished document, once `file_status` is `success`. Null before that. The link works for 24 hours. | | `failed_reason` | Why generation failed. | | `file_type` | `pdf-waybills` for A4/A5/A6 waybills, `pdf-sticker-waybills` for stickers. | | `metadata.has_multiple` | True when the file covers more than one shipment. | | `metadata.sent_by_email` | Always false for an API key. It is only set when a file asked for from the Bob Go app takes more than a minute, and is emailed instead. | Poll every few seconds until `file_status` leaves `processing`. A batch of several hundred labels takes appreciably longer than a handful, so back off rather than polling tightly. Records are kept for a month, so collect the file when it is ready rather than keeping the id for later. --- # Get tracking events `GET /v2/tracking?tracking_reference=UASDRW78` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/tracking?tracking_reference=UASDRW78' \ -H "Authorization: Bearer " ``` Returns the tracking history for a shipment: every checkpoint the courier has reported, plus the shipment's current status and the courier's branding. This is the pull side of tracking. If you want the events as they happen rather than on a schedule, subscribe to the `tracking/updated` [webhook](../11_Webhooks/README.md), which delivers the same object, so the same parsing code handles both. Unlike every other endpoint, this one is not tied to your account: it looks the tracking reference up across Bob Go, the way the public tracking page does. ## Parameters | Name | Type | Required | Notes | | --- | --- | --- | --- | | `tracking_reference` | string | yes | Bob Go's tracking reference for the shipment. | There is no paging: every checkpoint comes back, and `limit` and `offset` are ignored. ## Response **The response is an array**, even when you asked for one tracking reference. Take the first element. ```json [ { "id": "UASDRW78", "shipment_tracking_reference": "UASDRW78", "status": "delivered", "status_friendly": "Delivered", "courier_slug": "ie", "courier_name": "Internet Express", "courier_phone": "011 973 3566", "courier_logo": "https://ik.imagekit.io/.../ie_logo.png", "service_level": "ECO - Economy", "merchant_name": "Bogus Company", "merchant_logo": "https://ik.imagekit.io/.../logo.jpg", "show_branding": true, "type": "shipment", "order_number": "1908", "channel_order_number": "1908", "last_checkpoint_time": "2023-06-05 12:15:41+00:00", "shipment_time_created": "2023-06-02 09:36:05+00:00", "shipment_estimated_delivery_date_from": "", "shipment_estimated_delivery_date_to": "", "checkpoints": [ { "status": "delivered", "status_friendly": "Delivered", "country": "", "zone": "", "city": "", "zip": "", "location": "", "message": "PIN entered successfully", "time": "2023-06-05 14:15:41+02:00", "time_string": "14:15" }, { "status": "out-for-delivery", "status_friendly": "Out for delivery", "country": "", "zone": "", "city": "", "zip": "", "location": "JHB", "message": "", "time": "2023-06-05 06:19:18+02:00", "time_string": "06:19" } ], "shipment_movement_events": { "collected_time": "2023-06-02 12:37:35+00:00", "in_transit_time": "2023-06-04 09:53:10+00:00", "out_for_delivery_time": "2023-06-05 12:11:56+00:00", "ready_for_pickup_time": "", "delivered_time": "2023-06-05 12:15:41+00:00" } } ] ``` | Field | Notes | | --- | --- | | `status` | The shipment's current tracking status. Use this for display. | | `status_friendly` | The same status, written for a human. | | `checkpoints` | Every event the courier reported, most recently received first. That is usually, but not always, the order they happened in, so sort on `time` if the order matters. | | `grouped_checkpoints` | The same events grouped by day and sorted by `time`, for rendering a timeline. | | `shipment_movement_events` | The five milestone timestamps, extracted from the checkpoints, and empty until reached. Usually the only part you need. | | `courier_*`, `merchant_*`, `show_branding` | For rendering a tracking page. `show_branding` reflects the merchant's setting. | | `last_checkpoint_time` | When the courier last reported anything. | | `shipment_estimated_delivery_date_from` / `_to` | The estimated window, as full timestamps. Empty once the shipment is delivered or otherwise finished, and while it waits at a pickup point. | `id` and `custom_order_name` are still returned but are due to be discontinued. Key on `shipment_tracking_reference` and `channel_order_number` instead. Three things to expect from real courier data: - **Fields are often blank.** `country`, `zone`, `city` and `zip` on a checkpoint are always empty, and `location` and `message` depend entirely on what the courier sends. Render what is there rather than requiring it. - **Times are strings, not RFC 3339, and not all in one zone.** Checkpoint `time` and the estimated delivery window are South African time (`2023-06-05 14:15:41+02:00`), and `time_string` is only the time of day. `last_checkpoint_time`, `shipment_time_created` and the `shipment_movement_events` are UTC (`+00:00`). Parse the offset rather than assuming one. - **Errors have their own shape.** `message` holds a JSON string rather than text. Parse it, and the error is in `data.message` and `data.code`. A tracking reference that does not exist comes back as a `500`. Pickup point shipments carry extra fields, `collection_location`, `delivery_location`, `pickup_point_provider_name` and the `show_pickup_point_details` flags, for rendering locker collection details. > The `shipment` object, with the collection and delivery contact details, is > **only** included in the `tracking/updated` webhook payload. It is not > returned here. --- # Webhooks Bob Go pushes events to a URL you own, so you do not have to poll. Subscribe per topic, per delivery URL. ## Topics | Topic | Fires when | | --- | --- | | `tracking/updated` | A tracking event is received for a shipment. | | `fulfillment/created` | A fulfilment on an order has been created successfully. Not sent for returns. | | `shipment_submission_status/updated` | A shipment's submission status changes. | | `shipment_charged_amount/updated` | The amount charged for a shipment changes. | | `shipment_charged_weight/updated` | The weight charged for a shipment changes. | | `shipment_health_status/updated` | A shipment's health status changes. | | `order/updated` | An existing order changes. Creating an order does not fire it. | ## Endpoints | | | | --- | --- | | [`POST /v2/webhooks`](webhooks_post.md) | Subscribe, or change a subscription. | | [`GET /v2/webhooks`](webhooks_get.md) | List your subscriptions. | | [`DELETE /v2/webhooks`](webhooks_delete.md) | Remove subscriptions. | ## How delivery works When the event happens, Bob Go sends a `POST` to your delivery URL with the payload as JSON. Every delivery carries these headers: | Header | Value | | --- | --- | | `Content-Type` | `application/json` | | `X-BobGroup-Topic` | The topic, e.g. `tracking/updated`. | | `X-BobGroup-Type` | `webhook` | | `bobgo-webhook-signature` | The signature, once your account has a webhook secret. See below. | Deliveries stop while the account is inactive or closed. **Answer with a `2xx`.** Anything else counts as a failure. Bob Go retries a `429` or a `5xx` every 15 minutes. It does not retry any other `4xx`, or a delivery it could not complete at all, such as a refused connection or a timeout. **Answer within 20 seconds.** A delivery still waiting after that is abandoned and not retried. Acknowledge first, then do the work: queue the payload and return, rather than processing inline. **Failing deliveries switch the subscription off.** The first failure sets `first_failed_time` on the subscription, and a delivery that succeeds clears it. If deliveries are still failing three days later, the next failure sets the subscription `inactive` and the account gets an alert in the dashboard and by email. Set it back to `active` with [`POST /v2/webhooks`](webhooks_post.md) once your endpoint is fixed. **Expect repeats.** A webhook may arrive more than once. Key your processing on something stable, either `tracking_reference` plus the checkpoint time or the fulfilment `id`, so a repeat is a no-op rather than a duplicate shipment in your system. Keep a log of what you received and how you answered. When something is missing weeks later, that log is the only way to tell a webhook that never arrived from one that arrived and was dropped. ## Verifying the signature Generate a webhook secret in the dashboard under **Settings → Webhook subscriptions**. From then on every delivery carries `bobgo-webhook-signature`: the HMAC-SHA256 of the raw request body, keyed with the secret, and base64-encoded. To check it, compute the same HMAC over the body bytes exactly as you received them, before any JSON parsing, using the secret string as the key as it is (do not hex-decode it). Compare the base64 result with the header, and reject a delivery that does not match. Regenerating the secret takes effect immediately, with no overlap, so deploy the new secret as soon as you create it. Deleting it stops the signature being sent. Even with a signature, treat the URL as public, and do not act on a payload without confirming it against your own records. ## Payloads ### `tracking/updated` Sent whenever a tracking event is received. The payload is the tracking object that [`GET /v2/tracking`](../10_Tracking%20events/tracking.md) returns, as a single object rather than an array, plus a `shipment` object with the contact details. It carries the **full** history for the shipment, not just the new checkpoint, so you can replace your copy rather than merging. ```json { "shipment_tracking_reference": "UAEG4Z55", "status": "delivered", "status_friendly": "Delivered", "checkpoints": [{ "...": "..." }], "shipment_movement_events": { "...": "..." }, "shipment": { "collection_contact_name": "John", "collection_contact_surname": "Doe", "collection_contact_email": "john@doe.com", "collection_contact_mobile_number": "+27123456789", "delivery_contact_name": "Jane", "delivery_contact_surname": "Doe", "delivery_contact_email": "jane@doe.com", "delivery_contact_mobile_number": "+27123456789", "tracking_url": "https://..." } } ``` The fields are described on [Get tracking events](../10_Tracking%20events/tracking.md). ### `fulfillment/created` Sent once a fulfilment on an order has been created successfully: a shipment or a buyer collection. An order can produce several. A return is a fulfilment too, but it does not send this webhook. Follow a return through its shipment instead, with `shipment_submission_status/updated` and `tracking/updated`. ```json { "id": 6432, "account_id": 2, "channel_ref_id": "", "order_id": 22837, "channel_order_number": "22837", "type": "original", "method": "shipment", "method_id": 14273, "method_reference": "UADPWTTF", "method_status": "pending-collection", "status": "success", "failed_reason": "", "time_created": "2022-10-20T19:42:00.642739Z", "time_modified": "2022-10-20T19:42:02.397912Z", "order_items": [ { "id": 31119, "description": "Product 1", "fulfilled_qty": 1, "qty": 1, "sku": "", "total_price": 599, "total_weight_kg": 0.1, "unit_price": 599, "unit_weight_kg": 0.1, "vendor": "GoPugMe", "channel_ref_id": 12116233191527 } ] } ``` `type` is `original`, or `replacement` when the fulfilment replaces an earlier one. `qty` and `fulfilled_qty` on each line are the order line's totals so far, not the quantity in this fulfilment. ### `order/updated` Sent when an existing order changes: an update, a status or payment change, a tag added or removed, archiving or unarchiving. The payload is the order as it now stands. ```json { "id": 22837, "account_id": 1234, "channel_order_number": "ABC123", "channel_ref_id": "", "currency": "ZAR", "customer_name": "John", "customer_surname": "Doe", "customer_email": "john@doe.com", "customer_phone": "0123456789", "date_placed_on_channel": "2026-09-05T08:30:00Z", "delivery_address": { "...": "..." }, "fulfillment_status": "unfulfilled", "order_items": [{ "...": "..." }], "note": "", "payment_status": "paid", "status": "processing", "tags": [{ "name": "VIP", "color": "#ffcc00" }], "total_discount": 0, "total_price": 1500, "total_qty": 15, "total_tax": 195.65, "total_weight_kg": 1.5, "total_fulfilled_qty": 0, "buyer_selected_shipping_cost": 49.99, "buyer_selected_shipping_method": "Standard Shipping", "pickup_point_location_id": null, "pickup_point_provider_slug": null, "time_created": "2026-09-05T08:30:12Z", "time_modified": "2026-09-05T09:12:40Z" } ``` `order_items` has the same fields as on `fulfillment/created`. The status and payment values are listed on [Get orders](../6_Orders/order_list.md). ### Shipment topics `shipment_submission_status/updated`, `shipment_charged_amount/updated`, `shipment_charged_weight/updated` and `shipment_health_status/updated` all send **the whole shipment**, in the same shape. Which topic fired tells you what changed; the payload tells you what the shipment now looks like. The shape is close to, but not the same as, a shipment from `GET /v2/shipments`. ```json { "id": 644, "account_id": 68, "tracking_reference": "UASDX44J", "custom_tracking_reference": "631", "channel_order_number": "12345", "provider_slug": "demo", "provider_shipment_id": "123456789", "service_level_code": "ECO", "service_level_cut_off_time": "16:30:00", "status": "pending-collection", "submission_status": "success", "health_status": "healthy", "health_status_reason": "", "failed_reason": null, "rate": 117.3, "charged_amount": 117.3, "charged_weight_kg": 1, "submitted_weight_kg": 1, "submitted_vol_weight_kg": 0.2, "declared_value": 0, "parcel_count": 1, "parcels": [ { "description": "Watch box", "submitted_length_cm": 1, "submitted_width_cm": 1, "submitted_height_cm": 1, "submitted_weight_kg": 1, "actual_length_cm": null, "actual_width_cm": null, "actual_height_cm": null, "actual_weight_kg": null, "custom_parcel_reference": null, "tracking_reference": "UASDX44J/1" } ], "collection_address": { "id": 410, "entered_address": "22 Ealing Crescent, Bryanston, Johannesburg, 2191, Gauteng, South Africa", "type": "business", "company": "Bob Go", "street_address": "22 Ealing Crescent", "local_area": "Bryanston", "city": "Johannesburg", "zone": "GP", "code": "2191", "country": "South Africa", "lat": -26.044926, "lng": 28.024245 }, "delivery_address": { "...": "..." }, "collection_contact_name": "John", "collection_contact_surname": "Doe", "collection_contact_email": "john@doe.com", "collection_contact_mobile_number": "+27123456789", "delivery_contact_name": "Jane", "delivery_contact_surname": "Doe", "delivery_contact_email": "jane@doe.com", "delivery_contact_mobile_number": "+27123456789", "collection_after": "09:00:00", "collection_before": "15:00:00", "collection_min_date": "2022-10-13T11:22:17.521043Z", "collection_reference": "UASDX44J", "collected_date": null, "delivered_date": null, "instructions_collection": "", "instructions_delivery": "", "meta": { "collection_reference": "UASDX44J", "estimated_collection_date": "2022-10-13T10:52:18.190469Z", "estimated_delivery_date": "2022-10-18T06:00:00Z" }, "manifest_id": 0, "order_fulfillment": null, "tags": null, "tracking_expiry_date": null, "tracking_last_event_time": null, "tracking_url": "https://...", "time_created": "2022-10-13T10:52:17.533949Z", "time_modified": "2022-10-13T10:52:18.72622Z" } ``` What each topic is for: - **`shipment_submission_status/updated`**: the shipment reached, or failed to reach, the courier. Submission time varies, so this is the reliable signal that a booking is real. Read `submission_status` and `failed_reason`. - **`shipment_charged_amount/updated`**: the courier re-priced the shipment after measuring it. `charged_amount` is what you actually pay, and it can differ from what you quoted the buyer. - **`shipment_charged_weight/updated`**: the courier's measured weight changed. Usually arrives with, or just before, a charged-amount change. - **`shipment_health_status/updated`**: Bob Go's own assessment of whether the shipment is moving as it should, against its collection and delivery timelines. Read `health_status` and `health_status_reason` to find shipments that are stalled, at risk, or have hit a collection or delivery problem, without waiting for a customer to complain. --- # Subscribe to a webhook `POST /v2/webhooks` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.sandbox.bobgo.co.za/v2/webhooks' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "webhook_subscriptions": [ { "delivery_url": "https://somewebhookurl.com", "topic": "fulfillment/created", "status": "active" } ] }' ``` Creates subscriptions, or changes existing ones. Send them as an array in `webhook_subscriptions`, even when there is only one. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `webhook_subscriptions` | array | yes | The subscriptions to create or change. | | `webhook_subscriptions[].topic` | string | yes | One of the [topics](README.md#topics). | | `webhook_subscriptions[].delivery_url` | string | yes | Where to send the events. Use `https://`: Bob Go accepts any URL with a scheme and a host, but the payloads carry customer details. | | `webhook_subscriptions[].status` | string | yes | `active` or `inactive`. | | `webhook_subscriptions[].id` | integer | no | An existing subscription to change instead of creating a new one. `topic`, `delivery_url` and `status` are still required. An `id` that does not exist creates a new subscription. | One topic can have several delivery URLs. Within one request the same URL cannot be subscribed to the same topic twice, but nothing stops a second request from adding it again, and each copy gets its own deliveries. List your subscriptions before adding one. To switch a subscription back on after failures deactivated it, send its `id` with `status: "active"`. That also clears `first_failed_time`. ## Response **Every subscription on the account**, as a bare array ordered by topic, not only the ones you sent: ```json [ { "id": 364, "account_id": 1234, "topic": "fulfillment/created", "delivery_url": "https://somewebhookurl.com", "status": "active", "first_failed_time": null, "time_created": "2026-09-05T08:30:00Z", "time_modified": "2026-09-05T08:30:00Z" } ] ``` --- # Get webhook subscriptions `GET /v2/webhooks` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/webhooks' \ -H "Authorization: Bearer " ``` Lists the webhook subscriptions on your account, ordered by topic. | Name | Type | Notes | | --- | --- | --- | | `topic` | string | Filter to one topic. | | `status` | string | `active` or `inactive`. | | `limit` | integer | Page size. There is no default: leave it out and every subscription comes back. | | `offset`, `order`, `order_by` | | Standard [pagination](../1_Introduction/README.md#listing-and-pagination). | ## Response ```json { "webhook_subscriptions": [ { "id": 364, "account_id": 1234, "topic": "fulfillment/created", "delivery_url": "https://somewebhookurl.com", "status": "active", "first_failed_time": null, "time_created": "2026-09-05T08:30:00Z", "time_modified": "2026-09-05T08:30:00Z" } ], "count": 1 } ``` `first_failed_time` is the first place to look when events stop arriving. A non-null value means deliveries have been failing since then, and after three days of failures the subscription is set `inactive`. See [How delivery works](README.md#how-delivery-works). --- # Delete a webhook subscription `DELETE /v2/webhooks` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X DELETE 'https://api.sandbox.bobgo.co.za/v2/webhooks' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "ids": [ 364 ] }' ``` Removes one or more subscriptions, given their ids in the JSON body. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `ids` | array of integer | yes | The subscriptions to remove. | A successful delete returns `204` with no body. An id that does not exist is ignored rather than reported, so list your subscriptions afterwards if you need to be sure. --- # Get pickup point locations `GET /v2/locations?lat=-25.785641&lng=28.278871&stacked_height=5&stacked_width=38&stacked_length=40&total_weight=2` **Base URLs:** - Sandbox: `https://api.sandbox.bobgo.co.za` - Production: `https://api.bobgo.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.sandbox.bobgo.co.za/v2/locations?lat=-25.785641&lng=28.278871&stacked_height=5&stacked_width=38&stacked_length=40&total_weight=2' \ -H "Authorization: Bearer " ``` A pickup point is a locker or a staffed counter that stands in for a street address. It can be either end of a shipment: collect from one, deliver to one, or both. This endpoint finds them: by id, near a point, or inside a map area, optionally leaving out the ones a parcel will not physically fit into. Pickup points do not have their own rating or booking endpoints. They work through [Rates at checkout](../5_Rates%20at%20checkout/rates_at_checkout.md), [Rates](../8_Rates/rates.md) and [Shipments](../9_Shipments/shipment_create.md), with a couple of extra fields identifying the location instead of an address. ## Two ways in ### Let checkout choose The buyer picks a pickup point option at checkout, and you carry the identifiers through the rest of the flow. 1. Call `POST /v2/rates-at-checkout`. Pickup point options come back with `type: "pickup-point"`. 2. The chosen rate carries `pickup_point_location_id` and `provider_slug`. 3. Pass those into `POST /v2/rates` as `collection_pickup_point_location_id` or `delivery_pickup_point_location_id`, plus `pickup_point_provider_slug`. 4. Pass the same location id into `POST /v2/shipments`, plus `provider_slug`. A checkout rate for a pickup point looks like this: ```json { "id": 1731, "service_name": "Bob Box Locker - Shiplogic Locker Test (approx. 0.0 km)", "service_code": "bobgo_PP_1731_102_107_2", "total_price": 44.61, "type": "pickup-point", "pickup_point_location_id": 107, "provider_slug": "demo", "service_level_code": "BOXL-S" } ``` ### Choose the location yourself Show the buyer a map or a list, and drive it from `GET /v2/locations`. 1. Fetch locations near the buyer, or inside the map area they are looking at. 2. Take the `id` of the one they pick, together with its `provider_slug`. 3. Rate with that `id` as `collection_pickup_point_location_id` or `delivery_pickup_point_location_id`, and `pickup_point_provider_slug`. 4. Book with the same location id and `provider_slug`. ## Fields when rating | Scenario | Send | | --- | --- | | Pickup point → door | `collection_pickup_point_location_id`, `pickup_point_provider_slug` | | Door → pickup point | `delivery_pickup_point_location_id`, `pickup_point_provider_slug` | | Pickup point → pickup point | both location ids, plus `pickup_point_provider_slug` | ## Fields when booking | Scenario | Send | | --- | --- | | Pickup point → door | `collection_pickup_point_location_id`, `provider_slug` | | Door → pickup point | `delivery_pickup_point_location_id`, `provider_slug` | | Pickup point → pickup point | both location ids, plus `provider_slug` | Both ends must belong to the **same** provider. A shipment cannot run between two pickup points served by different couriers, and rating a mixed pair gives prices you cannot book. ## Finding locations Use one of these three ways: | | Send | | --- | --- | | One specific location | `location_id` **and** `provider_slug` | | Near an address | `lat` **and** `lng` | | Inside a map area | `min_lat`, `max_lat`, `min_lng`, `max_lng`, all four | `lat` and `lng` must be sent together, or the call fails with `Invalid coordinates.` `provider_slug` is required whenever `location_id` is used. Sending more than one way does not fail: `location_id` is used first, then the map area, then `lat` and `lng`, and the rest is ignored. A Bob Box locker scheduled for removal from the network stops appearing in searches 10 days before its removal date, so a buyer is not offered a locker that will be gone before their parcel reaches it. A lookup by `location_id` still returns it, so a locker somebody has already chosen keeps resolving. | Name | Type | Notes | | --- | --- | --- | | `location_id` | integer | A specific location. | | `provider_slug` | string | Restrict to one provider. Required with `location_id`. | | `provider_slugs` | array of string | Restrict to several providers. `provider_slug` overrides it. | | `lat`, `lng` | number | Search around this point. | | `min_lat`, `max_lat`, `min_lng`, `max_lng` | number | Bounding-box search. | | `limit` | integer | Cap the number of locations returned **per provider**, so `limit=1` with two providers returns two. Leave it out and each provider applies its own limit. | ## Filtering by parcel size These four are optional, and only take effect when **all four** are sent. Where there is more than one parcel, send the stacked dimensions, because locker compartments are checked against a single footprint. Only Bob Box lockers are checked this way. | Name | Notes | | --- | --- | | `stacked_height` | Total stacked height, in cm. | | `stacked_width` | Total stacked width, in cm. | | `stacked_length` | Total stacked length, in cm. | | `total_weight` | Combined weight of all parcels, in kg. | ## Response ```json { "locations": [ { "id": 107, "name": "Shiplogic Locker Test", "human_name": "Bob Box Locker - Menlyn", "type": "locker", "lat": -25.785641, "lng": 28.278871, "address": "125 Dallas Avenue, Menlyn, Pretoria", "full_address": "125 Dallas Avenue, Menlyn, Pretoria, 0181, GP", "trading_hours": "24/7", "provider_slug": "demo", "pickup_point_provider_name": "Bob Box", "pickup_point_provider_logo_url": "https://ik.imagekit.io/.../bobbox.png", "image_url": null, "active": true, "compartment_errors": [] } ], "count": 1 } ``` `id` and `provider_slug` are the pair you carry into the rate request and the shipment. `compartment_errors` explains why a location was returned but cannot take your parcel. Check it before offering the location to a buyer. It is null for providers that do not report compartments. A provider that fails to answer is left out rather than failing the call, so an empty `locations` array does not always mean there is nothing nearby.