Wholesale Solutions · Retailer integration
WSL Wholesale API
Pull your entitled catalogue, check live stock, place orders, and follow them through to dispatch, over a small JSON API. This guide gets your developer from a key to a live order.
https://api.wholesalesolutions.co.nz/api/wsl/v1
01 Overview
The WSL API is how your systems talk to our warehouse directly, with no spreadsheets and no FTP.
Every call is authenticated, returns JSON, and runs over HTTPS. There is no public surface: you see only the products, stock, and orders tied to your own account. Four things you can do:
Browse
Read the catalogue you're entitled to, with images, attributes, and your wholesale price.
Check stock
Live, per-SKU availability, cheap to poll, so you can keep your listings honest.
Order
Submit an order and get an immediate, all-or-nothing answer. Stock is checked at submission, not discovered at picking.
Track
Poll an order for its status, carrier, and tracking number once it dispatches, or ask us to push you a webhook the moment it ships.
Versioning
The version is in the path (/api/wsl/v1). We add to v1 without breaking it: new fields and new values may appear, so ignore what you don't recognise. The 2 October 2026 release was a one-off exception, made from integrator feedback while the API was new; the changes that may need code changes are marked in the Change log. Anything else that would break an integration comes with a new version.
02 Authentication
One key, sent as a bearer token, identifies you on every request.
Put your key in the Authorization header:
Authorization: Bearer wsl_live_9f2c…
Keys are environment-prefixed so a leak is obvious in a log:
| Prefix | Behaviour |
|---|---|
| wsl_test_… | Validates everything and returns real catalogue & stock, but can never create a real order: order calls always dry-run. Because it creates nothing, a test key's order history is empty (GET /orders returns no items and GET /orders/{ref} returns 404). Build and test against this, then switch to your live key to see real orders. |
| wsl_live_… | Creates real orders and real warehouse work. Switch to this only when you're ready to go live. |
Identity comes only from the key, never from anything else in the request. Any account or order id you send is ignored; you can only ever reach your own data.
What a failed auth looks like
| Status | Meaning |
|---|---|
| 401 | The Authorization header is missing or malformed. |
| 404 | The key is unknown, revoked, suspended, or expired. We deliberately never confirm whether a key exists: an invalid key is indistinguishable from "nothing here". |
03 Conventions
A handful of rules hold across every endpoint.
| Rule | Detail |
|---|---|
| correlationId | Every response carries one (also as the X-Correlation-Id header). Quote it to us in any support request: it's the one thing that lets us find your exact call. |
| Prices | Your wholesale price is always GST-exclusive, tagged "currency":"NZD" and "gstIncluded":false; GST is added on your invoice, not here. The rrp is the recommended retail price, which includes GST, tagged "rrpIncludesGst":true. |
| X-RateLimit-* | Limit, Remaining, and Reset headers report your daily quota so you can self-throttle. Your per-minute limit is in GET /ping (limits). |
| No value | A text field we have no value for says "N/A" (as colour and size do when they don't apply). Numbers, dates and the rrp are null instead, so they never break a numeric import. |
| Strict inputs | An unknown query parameter (400 unknown_parameter) or body field (400 unknown_field) is rejected, not ignored, so a typo fails loudly instead of silently doing nothing. |
Errors always take this shape. The field is named, the value never echoed back:
{
"error": "insufficient_stock",
"message": "Line 2: only 3 available.",
"correlationId": "7b1f…"
}
Field types
Every field in this guide is one of these. Watch the string-vs-number and the money types especially:
| Type | Notes |
|---|---|
| string | Text. A sku is a string that carries the product's numeric variant id (e.g. "10432"): send and read it as a JSON string, not a number. |
| integer | A whole number, e.g. quantity, available, page. |
| decimal | Money: NZD, up to two decimals (e.g. 12.34). All prices are this type. Wholesale prices are GST-exclusive; rrp includes GST. |
| boolean | true / false. |
| datetime | ISO 8601, UTC, with the Z suffix (e.g. 2026-09-03T02:10:00Z). |
| array | string[] or object[], an ordered list. object is a nested block of fields. |
A field marked nullable may be null or absent; required must be sent. correlationId is on every response, and the list endpoints (/products, /orders) also carry page, pageSize, and hasMore, not repeated in the tables below.
04 Catalogue & stock
Read the products you're entitled to, then check what's on the shelf.
Confirms your key works, tells you how many products you're entitled to, and gives you your limits. A good first call.
{
"retailer": "Acme Ltd",
"environment": "test",
"entitledProductCount": 42,
"discontinuedProductCount": 3,
"serverTimeUtc": "2026-09-03T02:10:00Z",
"pagination": { "defaultPageSize": 50, "maxPageSize": 200, "totalPagesAtDefault": 1, "totalPagesAt25": 2 },
"limits": { "requestsPerMinute": 60, "requestsPerDay": 10000, "ordersPerDay": 500, "maxSkusPerRequest": 200 },
"correlationId": "…"
}
| Field | Type | Notes |
|---|---|---|
| retailer | string | Your company name. |
| environment | string | "test" or "live", matching your key. |
| entitledProductCount | integer | How many products GET /products will return you, discontinued ones included. |
| discontinuedProductCount | integer | How many of those are "discontinued". |
| serverTimeUtc | datetime | Our server clock, UTC. |
| pagination | object | defaultPageSize (50) and maxPageSize (200) for /products, plus totalPagesAtDefault and totalPagesAt25: how many pages your entitled catalogue is at 50 and at 25 per page. Sizes your sync at a glance. |
| limits | object | Your own limits: requestsPerMinute, requestsPerDay, ordersPerDay, and maxSkusPerRequest (the most SKUs one /stock or /products?sku= call takes). See Rate limits. |
Your entitled catalogue, paged, including discontinued products (flagged). Wholesale prices are ex-GST; RRPs include GST.
| Parameter | Type | Notes |
|---|---|---|
| page | integer | 1-based page number. Default 1. |
| pageSize | integer | Default 50, capped at 200. |
| updatedSince | ISO 8601 UTC | Only products that changed on/after this time: any detail we publish, or their stock band. For incremental sync: see Keeping in sync below. |
| status | string | Optional. active or discontinued. Blank returns both. |
| sku | string, repeatable | Optional. Narrow to specific products: repeat one per SKU (?sku=10432&sku=10433) or comma-separate them (?sku=10432,10433), up to 200. Blank returns your full entitled catalogue. A SKU you can't get is simply omitted (use GET /stock to see which). Handy for refreshing just the products you list. |
{
"items": [ {
"sku": "10432", "status": "active", "barcode": "9421…", "title": "…", "brand": "…",
"images": ["https://…1.webp", "https://…2.webp"], "wholesalePriceExGst": 12.34,
"currency": "NZD", "gstIncluded": false, "rrp": 29.95, "rrpIncludesGst": true,
"colour": "Black", "size": "M", "material": "Silicone", "flavour": "N/A",
"width": 40, "height": 210, "depth": 35, "weight": 150,
"category": "Rabbit Vibrators",
"categories": [ { "master": "Sex Toys", "header": "Vibrators", "sub": "Rabbit Vibrators" },
{ "master": "Brands", "header": "Brands", "sub": "…" } ],
"nextExpiry": "2027-06-30T00:00:00Z", "updatedAtUtc": "2026-10-02T03:20:00Z"
} ], "page": 1, "pageSize": 50, "hasMore": true, "correlationId": "…"
}
Each entry in items is a product:
| Field | Type | Notes |
|---|---|---|
| sku | string | Numeric, the product's variant id, as a string. |
| status | string | "active", or "discontinued": every supplier we buy it from has discontinued it. See the note below. |
| barcode | string | EAN/UPC. Where a product has no barcode of its own, this is sometimes its SKU. "N/A" when we have none. |
| title | string | Product name. |
| brand | string | "N/A" when we have none. |
| description | string | HTML. Product dimensions are usually in here. "N/A" when we have none. |
| images | string[] | Image URLs, in order: up to 12, every image we have for the product (may be empty). |
| wholesalePriceExGst | decimal | Your price, ex-GST, NZD. |
| currency | string | Always "NZD". |
| gstIncluded | boolean | Always false: the wholesale price excludes GST. |
| rrp | decimal · nullable | Recommended retail price, GST-inclusive, NZD. null when we don't have an RRP for the product. |
| rrpIncludesGst | boolean | Always true. |
| colour | string | "N/A" when it doesn't apply. |
| size | string | "N/A" when it doesn't apply. |
| material | string | "N/A" when it doesn't apply. |
| flavour | string | "N/A" when it doesn't apply. |
| width | integer · nullable | Packaging width in millimetres. The product's own size is usually in its description. |
| height | integer · nullable | Packaging height in millimetres. |
| depth | integer · nullable | Packaging depth in millimetres. |
| weight | integer · nullable | Packed weight in grams. |
| category | string | The product's best single category: the first entry in categories. "N/A" when it has none. |
| categories | object[] | Every category the product is in, each as master › header › sub. Product types come first and brand categories last (the brand is also its own field). |
| nextExpiry | datetime · nullable | Expiry date of the next batch to ship (the oldest in-stock batch). null for non-perishable items or when out of stock. |
| updatedAtUtc | datetime · nullable | When the product last changed: any detail we publish (price, RRP, title, description, images, categories, status…), or its stock band. A stock figure moving within its band (66 to 65, say) doesn't count. |
GET /products/{sku} returns a single product (same fields), or 404 if it's unknown or not one of yours (the two are indistinguishable).
"status": "discontinued". It sells as normal while we have stock, and won't be restocked once it's gone, so delist it when GET /stock shows it at 0. A product only one of several suppliers has discontinued stays "active".?updatedSince= the time of your last sync. That returns only products whose details or stock band changed since, so a nightly sync is a handful of calls. We check for changes every 10 minutes or so, so ask from a little before your last sync rather than exactly at it. Change tracking started in October 2026: run a full pull once after that before relying on updatedSince.Live availability, per SKU. Deliberately lightweight so you can poll it often. Pass at least one ?sku=: repeat it, or comma-separate (?sku=10432,10433), up to 200 per call.
{
"asAtUtc": "2026-09-03T02:11:00Z",
"items": [ { "sku": "10432", "status": "active", "available": 25, "asAtUtc": "…" },
{ "sku": "99999", "status": "not_available", "available": null, "asAtUtc": "…" } ],
"correlationId": "…"
}
The response carries asAtUtc and an items array, one per SKU you asked for, in your order; each item is:
| Field | Type | Notes |
|---|---|---|
| sku | string | The SKU, as you sent it. |
| status | string | "active", "discontinued", or "not_available": not a product you can get through the API. That covers a mistyped or made-up SKU and one that isn't on your list; we don't say which. |
| available | integer · nullable | A conservative floor, the bottom of the stock band we're in, never below 0. See the note below. null when status is "not_available". |
| asAtUtc | datetime | When this figure was read. |
0, 1, 5, 25, or 50), so it's always at or below what we can supply. Before banding we keep a small margin back (2 units), so you're not left selling a last unit that's just gone: a product we're down to our last one or two of shows 0, and 0 always means you can't order it right now. It's a safe floor to list against: you'll never oversell what we can supply. Orders are checked against the same figure before banding, so an order can succeed for more than the band shows: the band is a floor for your listings, not a cap on what you can order.What changed since…
GET /stock?changedSince=2026-10-01T09:00:00Z lists only the SKUs whose stock band has changed since then (sold out, back in, or moved band), each with its live figure. It's paged like /products (page, pageSize up to 200, hasMore), so polling it through the day keeps your stock right without re-reading everything. Use either sku or changedSince in one call, not both. It can occasionally include a SKU whose band didn't move for you; that's harmless.
05 Placing orders
Submit an order; get an immediate yes or a precise no. Nothing is half-created.
You identify the order by your own reference (clientOrderRef). That reference is the idempotency key: send it twice and you get the original order back, never a duplicate.
{
"clientOrderRef": "PO-10021", // your ref — required, unique per order
"recipientName": "Jo Buyer",
"recipientEmail": "jo@example.com",
"recipientPhone": "021 000 0000", // required — courier contact
"deliveryAddress": {
"address1": "1 Queen St", "city": "Wellington",
"postcode": "6011", "country": "NZ" // NZ, or AU (state required)
},
"lines": [ { "sku": "10432", "quantity": 2 } ],
"dryRun": true // validate only, create nothing
}
Request body
| Field | Type | Notes |
|---|---|---|
| clientOrderRef | string · req | Your order reference, the idempotency key. Up to 40 characters. |
| recipientName | string · req | Who the parcel is addressed to. |
| recipientEmail | string · opt | |
| recipientPhone | string · req | A contact number for the courier. |
| deliveryAddress | object · req | See below. |
| lines | object[] · req | At least one line; see below. |
| deliveryInstructions | string · opt | Free text for the picker. |
| dryRun | boolean · opt | Default false. When true, validate only: nothing is created. You get back your prices and what you'd be charged (see Dry-run response). |
deliveryAddress
| Field | Type | Notes |
|---|---|---|
| address1 | string · req | |
| address2 | string · opt | |
| city | string · req | |
| state | string · AU only | Required for Australian addresses. |
| postcode | string · req | |
| country | string · req | "NZ" or "AU" (full names "New Zealand" / "Australia" also accepted): anything else is rejected. |
lines[]
| Field | Type | Notes |
|---|---|---|
| sku | string · req | Numeric, the variant id, as a string. |
| quantity | integer · req | Between 1 and 999. |
All-or-nothing validation
The order is validated as a whole. If anything is wrong, nothing is created and you get a 422 with a reason on every line, so you can fix and resubmit against the same ref:
{ "error": "validation_failed", "correlationId": "…",
"errors": [],
"lines": [
{ "sku": "10432", "quantity": 2, "ok": true, "reason": null, "available": 25 },
{ "sku": "99999", "quantity": 1, "ok": false, "reason": "unknown_sku", "available": null }
] }
On success you get 201 Created (or 200 if you're replaying a ref that already exists), with the accepted lines and their ex-GST unit prices. A test key, or "dryRun": true, creates nothing and returns the dry-run response instead: the same prices, and the payment a real order would show.
{
"clientOrderRef": "PO-10021",
"status": "Accepted",
"alreadyExisted": false,
"lines": [ { "sku": "10432", "quantity": 2, "unitPriceExGst": 12.34 } ],
"payment": { "method": "card", "amountCharged": 43.33, "gstIncluded": 5.65, "storeCreditApplied": 0.00, "shippingExGst": 13.00, "currency": "NZD" },
"correlationId": "…"
}
Success response
| Field | Type | Notes |
|---|---|---|
| clientOrderRef | string | Your reference, echoed back. |
| status | string | "Accepted" on create; the live status on a replay. |
| alreadyExisted | boolean | true if this ref had already been placed (a replay). |
| lines[].sku | string | The variant id. |
| lines[].quantity | integer | |
| lines[].unitPriceExGst | decimal | Your unit price, ex-GST. |
| payment.method | string | "card" (charged up front) or "invoice" (invoiced on your account terms). |
| payment.amountCharged | decimal · nullable | Card orders: the total charged, GST included, NZD. null for invoice orders. |
| payment.gstIncluded | decimal · nullable | Card orders: the GST on the order (worked out on the full order, before any store credit). null for invoice orders. |
| payment.storeCreditApplied | decimal · nullable | Store credit from your account spent on this order: on a card order already taken off amountCharged, on an invoice order taken off the invoice at dispatch. 0 when none was used. |
| payment.shippingExGst | decimal | The order's shipping, ex-GST, at the wholesale website's rates (see Shipping). Card and invoice orders alike: on a card order it's included in amountCharged, on an invoice order it's the Shipping line on your invoice. |
| payment.currency | string | Always "NZD". |
Dry-run response
A dry run (or any order on a test key) that would be accepted returns 200 with your prices and a preview of the payment, worked out exactly as a real order would be. Nothing is created, held or charged. One that wouldn't be accepted gets the same 422 as a real order.
{
"status": "dry_run",
"dryRun": true,
"wouldAccept": true,
"correlationId": "…",
"lines": [ { "sku": "10432", "quantity": 2, "available": 25, "unitPriceExGst": 12.34 } ],
"payment": { "method": "card", "amountCharged": 43.33, "gstIncluded": 5.65, "storeCreditApplied": 0.00, "shippingExGst": 13.00, "currency": "NZD" }
}
| Field | Type | Notes |
|---|---|---|
| status | string | "dry_run", or "dry_run_test_key" on a test key. |
| dryRun | boolean | Always true. |
| wouldAccept | boolean | true: the order would be accepted as it stands. |
| lines[].sku | string | The variant id. |
| lines[].quantity | integer | |
| lines[].available | integer | What you can order right now, as the same stock band GET /stock shows (0, 1, 5, 25 or 50). |
| lines[].unitPriceExGst | decimal | Your unit price, ex-GST: what the real order would use. |
| payment | object · nullable | The same fields as a real order's payment, as it would be if you placed the order now. On a card account, amountCharged is what your card would be charged: goods and shipping, plus GST for NZ accounts, less any store credit. Invoice accounts get "invoice" with no amounts, as on a real order. Both show shippingExGst. null only if we couldn't read your account just then. |
Rejected line (in a 422 or 409)
| Field | Type | Notes |
|---|---|---|
| sku | string | |
| quantity | integer | |
| ok | boolean | Whether this line passed. |
| reason | string · nullable | unknown_sku, quantity_invalid, not_entitled, or insufficient_stock. |
| available | integer · nullable | What you can order right now for this SKU, as the same stock band GET /stock shows (0, 1, 5, 25 or 50), so it is always safe to order. |
409 stock_changed and the short lines, rather than ship you a partial order. Re-check stock and resubmit.402 payment_declined and no order is created. We email the account holder a link to update the card, then resubmit the order (the same clientOrderRef is fine). Dry runs and test keys never charge.payment block shows how much was used.06 Shipping
Every order pays shipping at the same rates as the wholesale website. Which table applies depends on the delivery address, not your account.
- Delivering in New Zealand: by the order's goods value, ex-GST, before any store credit.
- Delivering to Australia: by the parcel's weight. That's the greater of its actual weight and its volumetric weight (length × width × height in mm ÷ 5,000, in grams), added up across the order from each product's
weight,width,heightanddepth. A product with no measurements counts as 0.
All rates are NZD, ex-GST. Each band starts at its lower figure, so an order of exactly $250.00 pays $20.00.
New Zealand: by goods value (ex-GST)
| Goods value | Shipping | Goods value | Shipping |
|---|---|---|---|
| Under $250 | $13.00 | $5,000 – $5,999.99 | $140.00 |
| $250 – $499.99 | $20.00 | $6,000 – $6,999.99 | $150.00 |
| $500 – $999.99 | $30.00 | $7,000 – $7,999.99 | $160.00 |
| $1,000 – $1,499.99 | $40.00 | $8,000 – $8,999.99 | $170.00 |
| $1,500 – $1,999.99 | $60.00 | $9,000 – $9,999.99 | $190.00 |
| $2,000 – $2,999.99 | $100.00 | $10,000 – $10,999.99 | $200.00 |
| $3,000 – $3,999.99 | $120.00 | $11,000 and over | $300.00 |
| $4,000 – $4,999.99 | $130.00 |
Australia: by parcel weight
| Parcel weight | Shipping | Parcel weight | Shipping |
|---|---|---|---|
| Under 1 kg | $18.34 | 16 – under 17 kg | $102.11 |
| 1 – under 2 kg | $23.57 | 17 – under 18 kg | $107.35 |
| 2 – under 3 kg | $28.81 | 18 – under 19 kg | $112.59 |
| 3 – under 4 kg | $34.05 | 19 – under 20 kg | $117.82 |
| 4 – under 5 kg | $39.28 | 20 – under 21 kg | $123.06 |
| 5 – under 6 kg | $44.52 | 21 – under 22 kg | $128.29 |
| 6 – under 7 kg | $49.75 | 22 – under 23 kg | $133.53 |
| 7 – under 8 kg | $54.99 | 23 – under 24 kg | $138.77 |
| 8 – under 9 kg | $60.23 | 24 – under 25 kg | $144.00 |
| 9 – under 10 kg | $65.46 | 25 – under 26 kg | $149.24 |
| 10 – under 11 kg | $70.70 | 26 – under 27 kg | $154.47 |
| 11 – under 12 kg | $75.93 | 27 – under 28 kg | $159.71 |
| 12 – under 13 kg | $81.17 | 28 – under 29 kg | $164.95 |
| 13 – under 14 kg | $86.41 | 29 – under 30 kg | $173.48 |
| 14 – under 15 kg | $91.64 | 30 kg and over | $180.00 |
| 15 – under 16 kg | $96.88 |
amountCharged. On account, it's the Shipping line on your invoice.payment.shippingExGst, and every placed order returns it too. For example, $24.68 of goods to an NZ address pays $13.00 shipping, so a card account is charged $43.33 including GST.07 Order status & tracking
Poll your order; tracking appears once it dispatches, or have us push it to you with a webhook.
Status for one of your orders. Another retailer's ref is a 404, as is any ref on a test key, which has no orders.
{ "clientOrderRef": "PO-10021", "status": "Dispatched",
"carrier": "NZ Post", "trackingNumber": "ABC123",
"trackingUrl": "https://www.nzpost.co.nz/tools/tracking/item/ABC123",
"dispatchedAtUtc": "…", "placedAtUtc": "…", "lines": […], "correlationId": "…" }
| Field | Type | Notes |
|---|---|---|
| clientOrderRef | string | Your reference. |
| status | string | AwaitingDispatch, Dispatched, or Cancelled. |
| carrier | string · nullable | Fills in at dispatch. |
| trackingNumber | string · nullable | Fills in at dispatch. |
| trackingUrl | string · nullable | A ready-made link straight to the carrier's tracking page, no need to build it yourself. Fills in at dispatch, alongside trackingNumber. |
| dispatchedAtUtc | datetime · nullable | When it left the warehouse. |
| placedAtUtc | datetime | When you submitted the order. |
| lines[] | object[] | sku string, quantity integer, unitPriceExGst decimal. |
status is one of AwaitingDispatch, Dispatched, or Cancelled. Poll once or twice a day; carrier, trackingNumber, and trackingUrl fill in at dispatch.
Your orders, newest first, from the last 24 months. Filter with ?status=, ?placedSince=, ?placedBefore=; paged like /products. On a test key this list is always empty, test keys create no orders.
08 Webhooks
Rather than polling for dispatch, we can push you an event the moment an order ships.
A webhook is optional and complements polling, it doesn't replace it. When one of your orders is dispatched, we POST an order.dispatched event to a URL you give us, so you can update your store and notify your customer straight away instead of waiting for your next poll of GET /orders.
GET /orders as the source of truth you reconcile against; don't rely on the webhook alone.Turning it on
There's no self-service dashboard: setup is handled by us, so it stays a deliberate, verified step.
- Stand up an HTTPS endpoint. A public URL that accepts a
POSTwith a JSON body and returns2xxquickly. HTTP and internal/private addresses are refused. - Send us the URL. Email it to your Wholesale Solutions account contact and ask to enable dispatch webhooks. We configure it against your account.
- Store your signing secret. We give you a secret (
whsec_…), shown once: keep it server-side. It's how you prove each call really came from us. - Verify a test event. We fire a
webhook.testevent at your endpoint; confirm you can validate its signature, and you're live.
What we send when an order dispatches. The body carries only your own order reference and the dispatch details, never your customer's personal data (you already hold that).
{
"id": "evt_dispatched_9f2c…", // stable event id — dedupe on this
"type": "order.dispatched",
"createdAtUtc": "2026-09-15T02:11:00Z",
"data": {
"clientOrderRef": "PO-10021", "status": "Dispatched",
"carrier": "NZ Post", "trackingNumber": "ABC123",
"trackingUrl": "https://www.nzpost.co.nz/tools/tracking/item/ABC123",
"dispatchedAtUtc": "2026-09-15T02:10:00Z",
"lines": [ { "sku": "10432", "quantity": 2, "unitPriceExGst": 12.34 } ]
}
}
The data block is the same PII-free shape as GET /orders/{ref}. We also send three headers:
| Header | Notes |
|---|---|
| X-WSL-Signature | t=<unix>,v1=<hex>, the timestamp and HMAC used to verify the call (below). |
| X-WSL-Event-Id | The event's id, repeated as a header. Use it to ignore a redelivered event you've already processed. |
| X-WSL-Event-Type | order.dispatched (or webhook.test). |
Verifying the signature
Every call is signed with your secret so you can be sure it's from us and hasn't been tampered with. Read t and v1 from the X-WSL-Signature header, then recompute the HMAC over the exact raw request body:
expected = HMAC_SHA256(secret, t + "." + rawBody) // hex, lowercase
- Compare constant-time. Check your
expectedequalsv1using a constant-time comparison, not==. - Reject stale calls. If
tis more than a few minutes old, reject it: this stops a captured request being replayed. - Sign the raw bytes. HMAC the body exactly as received, before any JSON re-serialisation: reformatting it changes the signature.
2xx as soon as you've stored the event, then do your processing in the background. Any non-2xx (or a timeout) is treated as a failure and retried with backoff, roughly after 1 min, 5 min, 30 min, 2 h, then 6 h, before we give up. Because an event can be redelivered, make handling idempotent: dedupe on X-WSL-Event-Id.09 Error reference
Every error is a status code plus a stable error string you can branch on.
| Status | Error | What it means |
|---|---|---|
| 400 | unknown_parameter · unknown_field | A query param or body field we don't recognise, usually a typo. |
| 400 | sku_required · too_many_skus | /stock needs at least one ?sku= (or ?changedSince=); the ?sku= list (on /stock and the optional /products filter) is capped at 200 per call. |
| 400 | invalid_query · invalid_status | /stock was given both sku and changedSince (use one); or /products?status= wasn't active or discontinued. |
| 401 | unauthorized | Missing or malformed Authorization header. |
| 403 | https_required | The call was made over plain http://. The API only accepts HTTPS, so switch to https://, and treat any key sent over plain http as exposed (ask us for a new one). |
| 402 | card_required | Your account pays by card, but there's no card saved with Stripe. Nothing was created or charged. The account holder can add one from the "manage billing" link in their Wholesale Solutions emails. |
| 402 | payment_declined | The saved card was declined (the bank's reason is in message). Nothing was created or charged. We email the account holder, so resubmit once the card is updated. |
| 404 | not_found | Unknown/revoked key, or a resource that isn't yours. We never confirm which. |
| 409 | stock_changed | Stock moved between validating and creating: the order was rejected whole. |
| 422 | validation_failed | Per-line reasons: unknown_sku, quantity_invalid, not_entitled, insufficient_stock. Order-level: address_incomplete, country_not_supported, no_lines, too_many_lines (more than 200 lines), daily_order_cap_reached, field_too_long (a value is longer than we can store, and the message names the field and its limit), invalid_characters (a line break or other control character in a single-line field), duplicate_sku (the same SKU on more than one line, so combine the quantities into one line). |
| 429 | too_many_requests · daily_limit_reached | You've hit a rate limit: the per-minute limit (too_many_requests), or your daily request allowance (daily_limit_reached, resets at midnight UTC). Retry-After is the number of seconds to wait. |
| 413 | payload_too_large | The request body is over 64 KB. A full 200-line order is well under 16 KB. |
| 503 | service_unavailable | The API is temporarily switched off for maintenance. Retry later. |
| 503 | payment_unavailable | We couldn't reach our payment provider. Nothing was created or charged. Retry shortly. |
10 Rate limits
Generous for real use, firm against runaways. Limits are per key.
| Limit | Notes |
|---|---|
| Per minute | A burst ceiling on requests: 60 a minute on the Standard tier. The minute is a fixed window that starts with your first call in it, so calls just before a burst count towards it. Exceed it and you get 429 too_many_requests, with Retry-After saying how many seconds until the window resets. |
| Per day | A daily request quota: 10,000 on the Standard tier, reported live in the X-RateLimit-* headers so you can pace yourself. |
| Orders per day | A cap on orders created: 500 on the Standard tier. A safety net against a buggy retry loop flooding the warehouse. |
Your own numbers are in GET /ping (limits). They're set on your account and can be adjusted, so talk to us if your volumes grow. A full catalogue pull at 200 per page is under 250 calls, and a nightly updatedSince sync or a /stock?changedSince= poll is far fewer, so day-to-day use sits comfortably inside any tier.
11 Getting started
From a test key to a live order, in order.
- Ping with your test key. Call
GET /ping: a name and an entitled count back means you're connected. - Pull your catalogue. Page through
GET /productsand map SKUs, prices, and images into your system. Remember wholesale prices are ex-GST and RRPs include GST. - Wire up stock. Read
GET /stock?sku=…for the SKUs you list, then keep it current withGET /stock?changedSince=…on the cadence that suits you. Delist anything that comes back"not_available", or"discontinued"at0. - Dry-run an order.
POST /orderswith"dryRun": true(or just your test key). Check the prices, shipping and thepaymentpreview it returns, and that you handle a422. - Go live. Swap in your
wsl_live_key, place one real order, which on a card-paying account is charged to your saved card, and watch it move throughGET /orders/{ref}toDispatchedwith tracking. Prefer a push over polling? Ask us to set up a webhook.
correlationId from the response, it points us straight at your exact request.12 Change log
What changed and when, newest first. Check your code marks a change that may need your integration updated.
4 November 2026
- Shipping now matches the wholesale website. Orders pay the website's shipping rates instead of a flat $10 + GST: by goods value when delivering in New Zealand (from $13.00 under $250), and by parcel weight when delivering to Australia (from $18.34 under 1 kg). Every band is listed in the new Shipping section.
- New
payment.shippingExGston orders and dry runs: the order's shipping, ex-GST, on card and invoice accounts alike. Nothing to change in your code.
8 October 2026
- Dry runs show the cost. A dry run (or a test-key order) now returns each line's
unitPriceExGstand thepaymentblock a real order would, so you can see what you'd be charged before placing it. Nothing changes in the request. The dry-run response is now documented. - Fewer in-stock products show as
0. The margin we keep back before banding is now 2 units instead of 5, so a product we hold 3 or more of shows as available. About 4,300 products moved up a band (most from0to1). Their stock band changed today, so your next/stock?changedSince=orupdatedSincesync picks them up. Nothing to change in your code.
2 October 2026
- Check your code Discontinued products stay in your catalogue with
"status": "discontinued", instead of being removed once they sell out. They sell while we have stock.GET /productstakes?status=activeif you only want the rest. - Check your code
GET /stockanswers every SKU you ask for. One you can't get through the API comes back as"status": "not_available"with"available": null, instead of being left out (or showing0). Every line now has astatus. - Check your code Text fields with no value say
"N/A"instead ofnull:barcode,brand,description,colour,size,material,flavour,category. Numbers and dates staynull. - Check your code
rrpincludes GST (the guide wrongly said ex-GST) and isnullwhen we have no RRP, including the old $999.99 placeholders. NewrrpIncludesGst, alwaystrue. - Check your code
updatedAtUtcis when the product last changed (any detail we publish, or its stock band), in UTC. It used to be the date it was first loaded.updatedSincenow uses it. Run one full pull after this release before relying on it. - New
GET /stock?changedSince=: the SKUs whose stock band changed since then, paged. - New
categories: every category a product is in, asmaster›header›sub.categoryis now the best single one, rather than whichever came first. Names no longer contain HTML codes such as%. - Up to 12 images per product (was 6).
- 200 SKUs per call now works on
/stockand/products?sku=(long lists were rejected before reaching us), and SKUs can be comma-separated. - The per-minute
429is now our standard JSON error (too_many_requests), andRetry-Afteris the real number of seconds to wait. GET /pingaddslimits(your per-minute, per-day, orders-per-day and SKUs-per-call limits) anddiscontinuedProductCount.- The guide now documents
width/height/depth/weightas packaging, and that abarcodeis sometimes the SKU.
30 September 2026
- Card payments: on a card-paying account, each live order is charged up front to the card saved with Stripe. New errors
402 card_required,402 payment_declinedand503 payment_unavailable, and apaymentblock on a successful order. - Store credit on your account is used automatically on your next order (
payment.storeCreditApplied). availablein dry-run,422and409order responses is banded, like/stock.clientOrderRefis limited to 40 characters. New422reasons:field_too_long,invalid_characters,duplicate_sku.- New errors:
403 https_required,413 payload_too_large,429 daily_limit_reached. - Fully discontinued products were removed once sold out. Reversed on 2 October: they now stay, flagged.
entitledProductCounton/pingmatches what/productsreturns.
15 September 2026
- First release of
v1: catalogue, stock, orders, order tracking, and dispatch webhooks.








