# Bob Shop Tools API The Bob Shop Tools API lets your own system manage your Bob Shop listings through an API source on Bob Sync. A **product** in this API becomes a **listing** on Bob Shop. A product can have several **variants**, for example sizes or colours of the same shirt. What you can do with it: - **Send products to us**, one at a time or in bulk from a JSON file. - **Remove listings** by deleting a product. - **Receive orders.** We send each paid order to a webhook URL you give us. Products are pushed by you to us. We never call your system to fetch them. The only request we make to you is the order webhook. ## How to read these docs | Section | What it covers | | --- | --- | | **[Authentication](../2_Authentication/README.md)** | Getting your token, sending it, and keeping it safe. | | **[Workflow](../3_Workflow/README.md)** | The integration in order. Start here. | | **[What makes a product go live](../4_What%20makes%20a%20product%20go%20live/README.md)** | Why a `200` is not a listing, and the category rule that catches most integrations out. Read it before you write code. | | **[Important concepts](../5_Important%20concepts/README.md)** | How products are identified, and the product, image, variant and option objects. | | **[Products](../6_Products/README.md)** | One product at a time: create, update, fetch, delete. | | **[Bulk upload](../7_Bulk%20upload/README.md)** | Many products in one JSON file. | | **[Webhooks](../8_Webhooks/README.md)** | How a paid order reaches you, and what we expect back. | ## Base URLs | Environment | Base URL | | --- | --- | | Sandbox | `https://api.tools.qa.bobshop.co.za/api/bobsync` | | Production | `https://api.tools.bobshop.co.za/api/bobsync` | Sandbox is our QA environment. The two are separate systems with separate tokens: a token for one does not work on the other. Going live means changing the base URL and the token. ## Getting set up An administrator installs an API source on Bob Sync for you, which generates your token. Contact [support](https://help.bobshop.co.za/) to get set up, then follow the [Workflow](../3_Workflow/README.md) section. ## Conventions - Requests and responses are JSON. Send `Content-Type: application/json` with a body. - Field names are `snake_case`. Prices are numbers in rands, for example `199.99`. Weights are kilograms. - A delete returns `204 No Content` with an empty body. - A failed request returns a plain-text message, not JSON, with the reason in the body and the meaning in the status code. For example `400` with `Invalid id.` - A field you leave out takes its type's default: `0`, `""`, `false` or `null`. ## Support Contact [help.bobshop.co.za](https://help.bobshop.co.za/) or email [techsupport@bobshop.co.za](mailto:techsupport@bobshop.co.za). When reporting a problem with a request, quote the `X-Request-ID` response header. For a webhook, quote either ID header from the request we sent you. --- # Authentication Every request carries a bearer token. The token identifies both you and your channel, so you never send a seller or channel id with a product. ```http POST /api/bobsync/products HTTP/1.1 Host: api.tools.bobshop.co.za Authorization: Bearer YOUR_UNIQUE_TOKEN Content-Type: application/json ``` ## Your token - It is generated when an administrator installs an API source on Bob Sync for your account. You cannot create one yourself. Contact [support](https://help.bobshop.co.za/). - **It is shown once**, when the channel is created, and cannot be viewed again or rotated. Store it as soon as you receive it. If it is lost, the channel has to be recreated, with a new token and a re-send of your catalogue. - **It does not expire.** It stays valid until the channel is removed. - **It is tied to one environment.** Sandbox is our QA environment, and a Sandbox token does not work on Production, or the other way round. You are issued one for each. Keep them apart so a test run cannot write to your live listings. ## Keeping it safe - Treat it as a password. Never put it in client-side code, a public repository or a support ticket. - Call the API over HTTPS only. - Store it as a secret: an environment variable or a secret manager, not source control. - If it is exposed, contact support at once. A leaked token means the channel has to be recreated. ## Failures | Status | Body | Meaning | | --- | --- | --- | | `401` | `Cookie, platform or header auth is required` | No `Authorization` header. | | `401` | `Invalid authentication token: ...` | The header has no `Bearer ` prefix. | | `401` | `api key is not valid` | The token is not recognised, or its channel has been removed. | | `401` | `invalid endpoint` | The token was sent to a path outside `/api/bobsync/`. | --- # Workflow The integration in order, from a new account to orders arriving. ## Step 1: Get your token and confirm your channel is ready Contact [support](https://help.bobshop.co.za/) to have an API source installed. Confirm two things with them: 1. **Which environment** the token is for. Build against Sandbox (QA) first. 2. **That your channel has a category rule.** Add one in Bob Sync, on your channel's Rules page. Without one, no product can become a listing. See [What makes a product go live](../4_What%20makes%20a%20product%20go%20live/README.md). ## Step 2: Send one product and confirm it lists Send **one** product with [Create or update a product](../6_Products/product_upsert.md): a name, a description, at least one image, and one variant with a price above zero and a quantity of at least one. A `200` means we stored the product, not that a listing exists. Check that the listing appeared on Bob Shop before going further. If it did not, see [What makes a product go live](../4_What%20makes%20a%20product%20go%20live/README.md). Store the `id` we return, per environment. Updates only need `product_reference`, but a delete needs the `id`. An `id` from the other environment does not exist and fails the request. ## Step 3: Send your catalogue - A few products: loop over [Create or update a product](../6_Products/product_upsert.md). - Many products: use [Bulk upload](../7_Bulk%20upload/README.md). Request an upload URL, upload a JSON array of products to it, queue it, and poll the status. A bulk file is a set of changes, not a replacement catalogue. Products you leave out are untouched. ## Step 4: Keep your catalogue up to date Send a product again whenever it changes. We match on `product_reference` within your channel, so the same reference updates the product rather than creating a duplicate. Send the product's **complete `variants` list** every time. The array replaces the product's variants, so a variant you leave out is deleted. To remove a listing, call [Delete a product](../6_Products/product_delete.md) with our `id`. Leaving a product out of a later bulk file does not remove it. ## Step 5: Receive orders and reconcile your stock Give us a **webhook delivery URL** and we POST each paid order to it. **We do not reduce the quantity you sent us when an item sells.** If your next update still carries the pre-sale quantity, the old number goes back onto the listing and the item can oversell. So on each order webhook: 1. Read `webhook_payload.orderItems`. `tradersReference` is the `sku` you supplied; `quantity` is how many sold. 2. Reduce the stock in your own system. 3. Send us the new quantity for the affected variants with [Create or update a product](../6_Products/product_upsert.md). The same order can arrive more than once. Key on `orderId` and ignore duplicates. See [Webhooks](../8_Webhooks/README.md) for the payload and delivery details. --- # What makes a product go live A `200` means we stored the product. A listing is created separately, and only if the product has everything Bob Shop requires: | Requirement | Where it comes from | | --- | --- | | A Bob Shop category | **Your channel's rules, not this API** | | A title | The product's `name` | | At least one image with a valid URL | The product's `images` | | A price above zero | Each variant's `price` | | A quantity of at least one | Each variant's `quantity` | ## The category rule **There is no category field in the product payload.** A rule on your channel assigns the category. Add it in Bob Sync, on your channel's Rules page. Without that rule every product you send is accepted with a `200`, stored, and never appears on Bob Shop. Nothing in the response says so. **If you get successful responses but no listings, check this first.** ## Some listing settings come only from rules Condition, delivery type, handling time, quantity per lot and discreet listing are set only by rules on your channel today. Values you send in these fields do not reach your listings. Add the rules in Bob Sync, on your channel's Rules page. ## Variants can fail individually A product can list while one of its variants does not. A variant is dropped from the listing if it has: - a price of zero or below, - a quantity of 100,000 or more, - or, on a product with two or more variants, no options. A variant with a quantity below 1 is marked as failed with `no items in stock`, but is still sent with zero stock while other variants have stock. The rest of the product still lists. In Bob Sync, the Not published count on your product source lists the products that did not publish and why. ## If every variant fails, the listing comes down When none of a product's variants are usable, or its total quantity is below 1, we close the existing listing rather than leave a stale one up. So a bad update to a listed product can take the listing down, not just fail to apply. The exception is an update with no variants at all. Today that leaves the listing live with its old stock. See [Create or update a product](../6_Products/product_upsert.md). Because the `variants` array replaces the product's variants, sending one incomplete variant to fix a single field discards the others and, if that one is not usable, closes the listing. **Always send the product's complete, valid variant list.** --- # Important concepts ## How we identify your products Two identifiers matter, and they are not interchangeable. **`product_reference`** is **your** reference for a product. It must be unique within your channel and stable, because it is what we match on. Sending an existing `product_reference` updates that product. **`id`** is **our** numeric key, returned on every successful push. It is required to delete a product; the delete endpoint does not accept `product_reference`. Store the `id` values we return. **`variant_id_text`** is your reference for a variant, unique within its product. An unrecognised value creates a new variant. ### Which one wins `id` takes precedence. If you send both and the `id` points at a different product, that product is updated and the `product_reference` you sent is ignored. For updates, send `product_reference` alone. Use `id` for deletes. **Never send an `id` from a different environment.** An `id` issued in Sandbox does not exist in Production, and the other way round. An unknown `id` fails the request, and in a bulk run stops the whole run part-way, leaving `is_running` set for up to an hour. Store the `id` values you receive per environment, so a Sandbox `id` is never sent to Production. ## Product The same shape everywhere: a single upsert and each element of a bulk file. ```json { "product_reference": "abc123", "name": "Demo Product", "description": "This is a product", "condition": "NEW", "delivery_type": "STANDARD_SHIPPING", "handling_time": 3, "quantity_per_lot": 1, "tags": "electronics,gadgets", "vendor": "ACME Inc.", "images": [ ... ], "variants": [ ... ] } ``` The fields are described on the [Create or update a product](../6_Products/product_upsert.md) page. ## ProductImage A product needs at least one image to be listed. `src` must be an absolute URL. `position` sorts the images ascending. ```json "images": [ { "src": "https://example.com/image1.jpg", "position": 1 } ] ``` ## Variant Every product needs at least one variant. Price and quantity live on the variant. ```json "variants": [ { "variant_id_text": "ab1", "sku": "SKU-ABC123", "price": 199.99, "quantity": 5, "weight": 1.23, "options": [ ... ] } ] ``` ## Option Options distinguish variants on the listing, such as size or colour. They are grouped by `name`: three variants with the name `Size` and values `Small`, `Medium` and `Large` give one `Size` selector with three choices. ```json "options": [ { "name": "Size", "value": "Large" } ] ``` - **One variant:** options are not required. Omit `options` or send `null`. The listing has no variant selector. - **Two or more variants:** every variant needs at least one option. A variant without one is dropped with the message `no variant options (ie: size, color) provided`. The rest of the product still lists, and if no variant is left the listing is closed. See [What makes a product go live](../4_What%20makes%20a%20product%20go%20live/README.md). --- # Products Create, read, update and delete products one at a time. A request body is capped at 1 MB on Sandbox and 6 MB on Production. A larger body is rejected with `413 Request Entity Too Large` before it reaches the API. To send many products at once, use [Bulk upload](../7_Bulk%20upload/README.md). See [Important concepts](../5_Important%20concepts/README.md) for how `product_reference` and `id` differ. ## Endpoints | | | | --- | --- | | `GET /api/bobsync/products` | Fetch one or more products by our `id`. | | `POST /api/bobsync/products` | Create a product, or update one. | | `DELETE /api/bobsync/products` | [Delete a product](product_delete.md) and close its listing. | --- # Fetch products `GET /api/bobsync/products?id=123` **Base URLs:** - Sandbox: `https://api.tools.qa.bobshop.co.za` - Production: `https://api.tools.bobshop.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.tools.qa.bobshop.co.za/api/bobsync/products?id=123' \ -H "Authorization: Bearer " ``` Retrieve one or more of your products by our numeric `id`. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `id` | integer | one of the two | A single id. | | `ids` | array of integer | one of the two | A JSON array, for example `ids=[123,456]`. A bare `123,456` is rejected. | **This is not a catalogue listing and has no pagination.** Store the `id` values we return when you push products. Products on another channel cannot be fetched. `condition`, `delivery_type`, `discreet_listing`, `gtin`, `handling_time` and `quantity_per_lot` always read back empty, because they are not stored on the product. ## Failures | Status | Body | Meaning | | --- | --- | --- | | `400` | `Invalid ids or id, please specify id or ids.` | Neither `id` nor `ids` was supplied. | | `400` | `invalid ids[0]` | `ids` was not sent as a JSON array, for example `ids=123,456`. | | `400` | `a product with a given id does not belong to the channel` | One of the ids belongs to another channel. | | `404` | `could not find product/s` | No product matched. | ## Response `200` with the matching products and a `count`. ```json { "products": [ { "id": 486875, "product_reference": "abc123", "name": "Demo Product", "description": "This is a product", "condition": "", "delivery_type": "", "discreet_listing": false, "gtin": null, "handling_time": 0, "quantity_per_lot": 0, "tags": "electronics,gadgets", "vendor": "ACME Inc.", "images": [ { "src": "https://example.com/image1.jpg", "position": 1 } ], "variants": [ { "variant_id_text": "ab1", "sku": "SKU-ABC123", "barcode": "123456789012", "description": "", "image_src": "https://example.com/image1.jpg", "name": "", "position": 1, "price": 199.99, "price_compare": 249.99, "quantity": 5, "weight": 1.23, "options": null } ] } ], "count": 1 } ``` --- # Create or update a product `POST /api/bobsync/products` **Base URLs:** - Sandbox: `https://api.tools.qa.bobshop.co.za` - Production: `https://api.tools.bobshop.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.tools.qa.bobshop.co.za/api/bobsync/products' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "product_reference": "abc123", "name": "Demo Product", "description": "This is a product", "condition": "NEW", "delivery_type": "STANDARD_SHIPPING", "discreet_listing": false, "gtin": null, "handling_time": 3, "quantity_per_lot": 1, "tags": "electronics,gadgets", "vendor": "ACME Inc.", "images": [ { "src": "https://example.com/image1.jpg", "position": 1 } ], "variants": [ { "variant_id_text": "ab1", "sku": "SKU-ABC123", "barcode": "123456789012", "image_src": "https://example.com/image1.jpg", "position": 1, "price": 199.99, "price_compare": 249.99, "quantity": 5, "weight": 1.23 } ] }' ``` Send the product without an `id` to create it. If the `product_reference` already exists on your channel, that product is updated instead. To update, send the same `product_reference` again, or our `id`. The response is the product as stored, including the `id` we assigned. - A `200` means we stored the product, not that a listing exists. Your channel needs a category rule first. See [What makes a product go live](../4_What%20makes%20a%20product%20go%20live/README.md). - The `variants` array replaces the product's variants. A variant you leave out is deleted. Always send the complete list. Do not send an empty list or leave `variants` out: today that deletes every variant but leaves the listing live with its old stock. To take a product down, use [Delete a product](product_delete.md). - `id` values are per-environment and take precedence over `product_reference`. Only send one you are certain of. See [Important concepts](../5_Important%20concepts/README.md). ## Product fields `condition`, `delivery_type`, `discreet_listing`, `handling_time` and `quantity_per_lot` do not reach your listings today. Only rules on your channel set them. Add the rules in Bob Sync, on your channel's Rules page. `gtin` cannot be set at all today. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `id` | integer | no | Our key, assigned on create. Per-environment. Takes precedence over `product_reference`. | | `product_reference` | string | yes | Your reference for the product. Unique within your channel and stable. | | `name` | string | yes | The listing title. Required to list. | | `description` | string | yes | Cut off at 7,500 characters. HTML is allowed; scripts, embeds, iframes, objects and forms are stripped. | | `condition` | string | no | `NEW`, `SECOND_HAND`, `REFURBISHED` or `OPEN_BOX`. Set only by a channel rule today, not by this field. | | `delivery_type` | string | no | `STANDARD_SHIPPING`, `DIGITAL_DELIVERY`, `ALLOWS_COLLECTION` or `COLLECTION_ONLY`. Set only by a channel rule today, not by this field. | | `discreet_listing` | boolean | no | Hides the buyer's user name. Defaults to `false`. Set only by a channel rule today, not by this field. | | `gtin` | string | no | Global Trade Item Number. **Not currently carried through to listings.** Defaults to `null`. | | `handling_time` | integer | no | Days to have an order ready for collection, at most 15. Set only by a channel rule today, not by this field. | | `quantity_per_lot` | integer | no | Minimum purchase quantity. Defaults to 1. Set only by a channel rule today, not by this field. | | `tags` | string | no | Comma-separated tags for the listing. | | `vendor` | string | no | The vendor of the product. | | `images` | array of ProductImage | yes, to list | A product with no images cannot become a listing. | | `variants` | array of Variant | yes, to list | At least one. **Replaces the product's variants; a variant left out is deleted.** | ## ProductImage fields | Field | Type | Required | Notes | | --- | --- | --- | --- | | `src` | string | yes | An absolute URL. | | `position` | integer | no | Sort order, ascending. | ## Variant fields | Field | Type | Required | Notes | | --- | --- | --- | --- | | `variant_id_text` | string | yes | Your reference for the variant, unique within the product. An unrecognised value creates a new variant. | | `sku` | string | recommended | Shown on the listing and returned as `tradersReference` on order webhooks. Your key for matching a sale to your stock. | | `price` | number | yes | Must be above zero to list. | | `quantity` | integer | yes | At least 1 and below 100,000 to list. | | `weight` | number | recommended | Kilograms. Used to estimate shipping at checkout. | | `price_compare` | number | no | Shown struck through, only when greater than `price`. | | `image_src` | string | no | The variant's image. If the URL is also in `images`, the listing switches to it when the variant is selected. | | `barcode` | string | no | Barcode data. Stored but not currently displayed. | | `position` | integer | no | Sort order, ascending. | | `name` | string | no | On a product with one variant, added to the listing title as "product name - variant name". Not displayed on a product with two or more variants. | | `description` | string | no | On a product with one variant, replaces the product description when not empty. Not displayed on a product with two or more variants. | | `options` | array of Option | conditional | Size, colour and so on. **Required when the product has two or more variants.** See [Important concepts](../5_Important%20concepts/README.md). | ## Validation This endpoint does not currently check `product_reference`, `condition`, `delivery_type` or `handling_time`. A missing reference or an unrecognised value is accepted with a `200`. [Bulk upload](../7_Bulk%20upload/README.md) does check them. Send valid values on both. ## Listing-detail fields in the response `condition`, `delivery_type`, `discreet_listing`, `gtin`, `handling_time` and `quantity_per_lot` are not stored on the product, so the response shows: | Call | What the response shows | | --- | --- | | Create | Empty: `""`, `0`, `false` or `null`. | | Update | The values you sent. | | [Fetch products](product_fetch.md) | Empty, always. | An empty value does not mean the request was rejected. ## Failures | Status | Body | Meaning | | --- | --- | --- | | `400` | `failed to JSON decode the request body ...` | The body is not valid JSON. | | `404` | `failed to get product: ...` | The product lookup failed on our side. Retry. | | `413` | `{"message":"Request Entity Too Large"}`, or an HTML page on Sandbox | The body is over 1 MB on Sandbox or 6 MB on Production. | | `429` | `... is busy. Will retry...` | The same product is already being processed. Retry shortly. | | `500` | `product does not belong to given channel` | The `id` belongs to a product on another channel. | | `500` | `handler failed, because handler function crashed: ...` | The `id` does not exist on this environment. See [Important concepts](../5_Important%20concepts/README.md). | | `500` | `failed to insert product`, `failed to update product` or `failed to update product variants` | A write failed on our side. Retry. | A `500` with either of the first two messages is caused by the request. Check the `id` you sent before retrying. ## Response `200` with the stored product, including the `id` we assigned. The example is a create, so the listing-detail fields are empty. ```json { "id": 486875, "product_reference": "abc123", "name": "Demo Product", "description": "This is a product", "condition": "", "delivery_type": "", "discreet_listing": false, "gtin": null, "handling_time": 0, "quantity_per_lot": 0, "tags": "electronics,gadgets", "vendor": "ACME Inc.", "images": [ { "src": "https://example.com/image1.jpg", "position": 1 } ], "variants": [ { "variant_id_text": "ab1", "sku": "SKU-ABC123", "barcode": "123456789012", "description": "", "image_src": "https://example.com/image1.jpg", "name": "", "position": 1, "price": 199.99, "price_compare": 249.99, "quantity": 5, "weight": 1.23, "options": null } ] } ``` --- # Delete a product `DELETE /api/bobsync/products?id=1234` **Base URLs:** - Sandbox: `https://api.tools.qa.bobshop.co.za` - Production: `https://api.tools.bobshop.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X DELETE 'https://api.tools.qa.bobshop.co.za/api/bobsync/products?id=1234' \ -H "Authorization: Bearer " ``` Delete a product. The corresponding listing on Bob Shop is closed. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `id` | integer | yes | Our id for the product. | The endpoint takes our `id` only, not `product_reference`. The `id` is in the response to every successful push. A successful delete returns `204 No Content` with an empty body. The deletion is queued, so the listing closes shortly after, not at once. ## Failures | Status | Body | Meaning | | --- | --- | --- | | `400` | `Invalid id.` | `id` was missing or not a positive number. | | `400` | `product does not belong to channel` | The product belongs to another channel. | | `404` | `could not find product with id: 1234` | No product with that id. | --- # Bulk upload Send many products at once, without the request body limit of a single upsert (1 MB on Sandbox, 6 MB on Production). 1. **[Get a bulk upload URL](bulk_upload_url.md)** returns a signed URL. 2. **Upload your JSON file to that URL** with an HTTP `PUT`. The file is an array of products, each in the same format as a single upsert. See [Bulk file format](bulk_file_format.md). 3. **[Start a bulk upload](bulk_upload_start.md)** with the same URL. Processing happens in the background. [Check bulk upload status](bulk_upload_status.md) follows it, and [Cancel a bulk upload](bulk_upload_cancel.md) stops it. ## Endpoints | | | | --- | --- | | `GET /api/bobsync/products-bulk` | Get a signed URL to upload the file to. | | `POST /api/bobsync/products-bulk` | Queue the uploaded file for processing. | | `GET /api/bobsync/products-bulk/status` | Check how a run is going, or how the last one finished. | | `POST /api/bobsync/products-bulk/cancel` | Stop a running upload. | ## A bulk file is a set of changes, not a replacement catalogue - Products in the file are created or updated. - Products missing from the file are left alone. Their listings stay live. - To remove a listing, call [Delete a product](../6_Products/product_delete.md). Leaving a product out of a later file does nothing. A daily file containing only what changed is a good way to use these endpoints. ## But a product's variants are replaced Within each product you send, the `variants` array replaces that product's variants. A variant we hold that is not in the array is deleted, along with its part of the listing. Send a product's complete variant list every time, even for a one-field change. The single-product upsert works the same way. ## Errors do not stop the run, with one exception Each product is processed on its own. A failed product is recorded in the status and the run continues. The exception is an `id` that does not exist, most often an `id` from the other environment. That stops the run part-way and leaves `is_running` set for up to an hour, until the status record expires. See [Important concepts](../5_Important%20concepts/README.md). --- # Get a bulk upload URL `GET /api/bobsync/products-bulk` **Base URLs:** - Sandbox: `https://api.tools.qa.bobshop.co.za` - Production: `https://api.tools.bobshop.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.tools.qa.bobshop.co.za/api/bobsync/products-bulk' \ -H "Authorization: Bearer " ``` Returns a signed URL, valid for 7 days. Upload your file to it with an HTTP `PUT`, then pass the same URL to [Start a bulk upload](bulk_upload_start.md). The file is a JSON array of products, each in the same format as a single upsert. See [Bulk file format](bulk_file_format.md). ## Failures | Status | Body | Meaning | | --- | --- | --- | | `400` | `could not generate temporary file` or `could not get upload url` | We could not create the upload URL. Retry. | ## Response `200` with the signed upload URL. ```json { "url": "https://bobshop-tools-backend-qa-infra-bulk-uploads.s3.af-south-1.amazonaws.com/0f8c...json?X-Amz-Algorithm=AWS4-HMAC-SHA256&..." } ``` --- # Start a bulk upload `POST /api/bobsync/products-bulk` **Base URLs:** - Sandbox: `https://api.tools.qa.bobshop.co.za` - Production: `https://api.tools.bobshop.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.tools.qa.bobshop.co.za/api/bobsync/products-bulk' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "channel_id": 1, "url": "https://bobshop-tools-backend-qa-infra-bulk-uploads.s3.af-south-1.amazonaws.com/example.json?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=TEST%2Faf-south-1%2Fs3%2Faws4_request&X-Amz-Date=20240628T092547Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&x-id=PutObject&X-Amz-Signature=TESTSIGNATURE" }' ``` Queue a previously uploaded bulk file for processing. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `channel_id` | integer | yes | The API channel your token belongs to. It is the `channel_id` in every order webhook, and support can give it to you before your first order. | | `url` | string | yes | The URL from [Get a bulk upload URL](bulk_upload_url.md) that you uploaded the file to. | One bulk upload runs at a time per channel. Poll [Check bulk upload status](bulk_upload_status.md) until `is_running` is `false` before starting another. Starting a run resets the counters and the error list. ## Failures | Status | Body | Meaning | | --- | --- | --- | | `400` | `invalid channel_id` | `channel_id` missing or not positive. | | `400` | `invalid url` | `url` was empty. | | `400` | `a bulk upload is already running` | A run is already in progress for this channel. | | `400` | `could not get channel` | The channel could not be found. | | `400` | `could not queue bulk upload` | The run could not be queued on our side. Call [Cancel a bulk upload](bulk_upload_cancel.md), then retry. Without the cancel, a retry gets `a bulk upload is already running` for up to an hour. | | `403` | `user 123 does not have access to channel 456` | The channel does not belong to you. | ## Response `200` with the run's status record, `is_running` set to `true`. ```json { "errors": null, "import_count": 0, "import_success_count": 0, "is_running": true } ``` --- # Check bulk upload status `GET /api/bobsync/products-bulk/status?channel_id=1` **Base URLs:** - Sandbox: `https://api.tools.qa.bobshop.co.za` - Production: `https://api.tools.bobshop.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X GET 'https://api.tools.qa.bobshop.co.za/api/bobsync/products-bulk/status?channel_id=1' \ -H "Authorization: Bearer " ``` How a bulk upload is progressing, or how the last one finished. The result is kept for one hour after the run's last write. Collect the error list within that hour. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `channel_id` | integer | yes | The API channel your token belongs to. Support can give it to you before your first order. | | Field | Meaning | | --- | --- | | `is_running` | Whether a run is in progress. | | `import_count` | Products attempted so far. | | `import_success_count` | Products that succeeded. The difference is the number that failed. | | `errors` | The reasons products failed, **de-duplicated**: a problem affecting 500 products appears once. Its length is not a failure count. | A channel that has never run a bulk upload, or whose last run finished more than an hour ago, returns zeroes with `errors: null`. ## Failures | Status | Body | Meaning | | --- | --- | --- | | `400` | `invalid channel_id` | `channel_id` missing or not positive. | | `400` | `could not get channel` | The channel could not be found. | | `403` | `user 123 does not have access to channel 456` | The channel does not belong to you. | ## Response `200` with the status record. ```json { "errors": [ "product reference empty", "handling time cannot be more than 15 days" ], "import_count": 1500, "import_success_count": 1498, "is_running": false } ``` --- # Cancel a bulk upload `POST /api/bobsync/products-bulk/cancel` **Base URLs:** - Sandbox: `https://api.tools.qa.bobshop.co.za` - Production: `https://api.tools.bobshop.co.za` **Authentication:** Bearer token required (see the Authentication page). ## Example request ```bash curl -X POST 'https://api.tools.qa.bobshop.co.za/api/bobsync/products-bulk/cancel' \ -H "content-type: application/json" \ -H "Authorization: Bearer " \ -d '{ "channel_id": 1 }' ``` Stop a bulk upload that is currently running. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `channel_id` | integer | yes | The API channel your token belongs to. Support can give it to you before your first order. | The run stops before the next product. Products already handled stay as they are. ## Failures | Status | Body | Meaning | | --- | --- | --- | | `400` | `invalid channel_id` | `channel_id` missing or not positive. | | `400` | `could not get channel` | The channel could not be found. | | `403` | `user 123 does not have access to channel 456` | The channel does not belong to you. | ## Response `200` with a fixed acknowledgement. Call [Check bulk upload status](bulk_upload_status.md) for the final counts. ```json { "errors": null, "import_count": 0, "import_success_count": 0, "is_running": false } ``` --- # Bulk file format A JSON array of products, each in the same format as a single upsert. Include `id` to target an existing product directly; otherwise `product_reference` is matched. The example shows a single-variant product with no options, and a two-variant product where options are required. ```json [ { "product_reference": "abc123", "name": "Demo Product", "description": "A simple product with one variant, so no options are needed", "condition": "NEW", "delivery_type": "STANDARD_SHIPPING", "handling_time": 3, "quantity_per_lot": 1, "tags": "electronics,gadgets", "vendor": "ACME Inc.", "images": [ { "src": "https://example.com/image1.jpg", "position": 1 } ], "variants": [ { "variant_id_text": "ab1", "sku": "SKU-ABC123", "position": 1, "price": 199.99, "quantity": 5, "weight": 1.23 } ] }, { "id": 1234, "product_reference": "def456", "name": "Demo T-Shirt", "description": "Two variants, so each one needs at least one option", "condition": "NEW", "delivery_type": "STANDARD_SHIPPING", "images": [ { "src": "https://example.com/shirt.jpg", "position": 1 } ], "variants": [ { "variant_id_text": "shirt-small", "sku": "SHIRT-S", "position": 1, "price": 149.0, "quantity": 10, "weight": 0.3, "options": [ { "name": "Size", "value": "Small" } ] }, { "variant_id_text": "shirt-large", "sku": "SHIRT-L", "position": 2, "price": 149.0, "quantity": 4, "weight": 0.35, "options": [ { "name": "Size", "value": "Large" } ] } ] } ] ``` `1234` is an example `id`. Use one from your own responses; an unknown `id` stops the run. ## Checks applied to each product The bulk run checks each product before storing it. A failed product is skipped, its reason is added to `errors` on [Check bulk upload status](bulk_upload_status.md), and the run continues. | Error | Meaning | | --- | --- | | `product reference empty` | `product_reference` was missing or empty. | | `NEW_ITEM is not in NEW,SECOND_HAND,REFURBISHED,OPEN_BOX` | Unrecognised `condition`. | | `POSTAGE is not in STANDARD_SHIPPING,DIGITAL_DELIVERY,ALLOWS_COLLECTION,COLLECTION_ONLY` | Unrecognised `delivery_type`. | | `handling time cannot be more than 15 days` | `handling_time` above 15. | | `product with id:123 does not belong to given channel` | The `id` belongs to a product on another channel. | --- # Webhooks When your API source is set up we ask you for a **webhook delivery URL**. When one of your Bob Shop orders is paid, we POST it to that URL. This is the only request we make to your system. We do not fetch products from you, and we do not send product, price or stock updates. ## Events | Event | Fires when | | --- | --- | | [Order created](order_created.md) | One of your Bob Shop orders is paid. See the [Order created](order_created.md) page for the payload. | ## There is no order-status or tracking API This API manages listings, not orders. There is no endpoint to fetch orders, and no way to send an order's status or a tracking number back to us. `sellersOrderStatus` in the payload is informational only. Order status and tracking are handled in the Bob Shop seller portal. ## Why webhooks matter for your stock We do not adjust the stock you sent us when an item sells. Your next product update is taken as is, so a pre-sale quantity puts the old number back on the listing and the item can oversell. After each order, reduce the stock in your own system and send us the new quantity for the sold variants with [Create or update a product](../6_Products/product_upsert.md). [Workflow](../3_Workflow/README.md), step 5, has the sequence. ## Securing your endpoint **These requests are not signed and carry no authentication.** Signed webhooks are on our roadmap. Until then, use the measures below. The headers we send are: | Header | Value | | --- | --- | | `User-Agent` | `Bob Shop Tools/1.0` | | `Content-Type` | `application/json` | | `X-Request-ID` | A UUID for the whole operation. | | `bobshop-tools-req-id` | A UUID for this delivery attempt. | Quote either ID header when reporting a problem to us. - Treat the delivery URL as a secret: a long, unguessable path, not published. - Treat the contents as untrusted until matched against your own records. - Our requests come from one fixed IP address per environment. Allow-list it as defence in depth, not as proof on its own: | Environment | IP address | | --- | --- | | Production | `13.245.115.171` | | Sandbox (QA) | `13.247.220.131` | ## What we expect back **Respond `200` within 2 minutes.** Any status from `200` to `399` counts as success, so do not answer with a redirect: a `3xx` means we will not send the order again. After 2 minutes we give up and count it as a failure. Acknowledge first, then process. **Retries are limited.** A `4xx`, a `5xx` or a timeout is retried, up to **4 attempts in total**, about 6 minutes apart. After that the delivery is set aside and not retried, so a handler that is down for more than about 20 minutes misses orders. There is no replay endpoint: contact support to have set-aside deliveries re-sent. Reconcile against the webhooks you did receive so a gap is easy to spot. **Expect repeats.** Retries can deliver the same order more than once, and if order pushing is also switched on for your API channel in Bob Sync, every order is delivered twice. Key on `orderId` and ignore duplicates. ## The envelope **Every webhook is wrapped in an envelope.** The order itself is under `webhook_payload`, alongside the `channel_id` it relates to. Read `webhook_payload`, not the top level: ```json { "channel_id": 1234, "webhook_payload": { ... } } ``` See the [Order created](order_created.md) page for the payload. --- # Order created Sent when one of your Bob Shop orders is paid. An unpaid or zero-value order is not sent. ## Example payload ```json { "channel_id": 1234, "webhook_payload": { "orderId": 1234567, "enabled": true, "sellerUserId": 123, "sellersOrderStatus": "", "totalAmount": "100.00", "totalAmountTaxAmount": "15.00", "totalPaidAmount": "135.00", "buyersShippingAmount": "20.00", "orderItems": [ { "orderItemId": 123456, "tradeId": 689438827, "tradeTitle": "Demo Product", "tradersReference": "SKU-ABC123", "quantity": 1, "itemAmount": "100.00", "totalOrderItemTaxAmount": "15.00", "productVariantOptions": [ { "name": "Size", "value": "Large" } ] } ], "paymentTransactions": [ { "paymentTransactionId": 12345, "paymentMethod": "EFT", "time": "2023-12-27T17:23:04Z", "paidAmount": "135.00" } ], "smallOrderCharge": null, "delivery": { "firstName": "John", "lastName": "Doe", "building": "Example House", "address1": "1 Example Street", "address2": "", "suburb": "Example Park", "city": "Pretoria", "province": "Gauteng", "zipCode": "0001", "country": "South Africa", "telephone1": "0123456789", "telephone2": "" }, "buyer": { "userId": 123, "alias": "JD", "firstName": "John", "lastName": "Doe", "emailAddress": "jdoe@example.com", "mobileNumber": "0123456789", "telephone1": "0123456789", "telephone2": "", "address1": "1 Example Street", "address2": "", "zipCode": "0001", "country": "South Africa", "createdTime": "2014-11-03T16:18:54Z", "status": "ENABLED", "negativeUserReviews": 1, "positiveUserReviews": 20 } } } ``` ## Fields | Field | Meaning | | --- | --- | | `channel_id` | The Bob Sync channel the order relates to. | | `orderId` | The Bob Shop order number. Use it to de-duplicate retries. | | `enabled` | Whether the order is active. Always `true`, since only paid orders are sent. | | `sellerUserId` | Your Bob Shop seller ID. | | Amount fields (`totalAmount`, `totalPaidAmount`, `itemAmount`, `buyersShippingAmount`, tax fields) | **Strings**, with a thousands separator from 1,000 up, for example `"5,440.00"`. Remove the separator before converting to a number. | | `orderItems[].tradeId` | The Bob Shop listing number the item was bought from. | | `orderItems[].tradersReference` | The `sku` you supplied for the variant. **This is your key for matching the sale back to your own stock.** | | `orderItems[].quantity` | How many units sold. Subtract this in your own system. | | `orderItems[].productVariantOptions` | The options of the variant that sold, for example `Size: Large`. Sent as `null` when the item has no options. | | `paymentTransactions` | Sent as `null` when not applicable. | | `smallOrderCharge` | Sent as `null` unless a small-order charge applies. When it does, an object with `tradeTitle`, `quantity`, `itemAmount` and `ItemAmountTaxAmount`. | Fields not listed are not sent: no order timestamps, shipping method names, order notes, trade images or item dimensions.