New arrivals just landed. Stock up before they sell through.Shop new products

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.

Version v1 Updated 4 November 2026 JSON over HTTPS Polling + webhooks
Base URL 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.

Paying for ordersHow orders are paid for depends on your trade account. On account, orders flow into our normal wholesale invoicing and appear on your monthly Xero invoice. Paying by card, each order is charged up front, when you place it, to the card you saved when you subscribed. That card is saved securely by Stripe, our payment provider. We never see or store your card details. If the charge is declined, the order isn't placed. Any store credit on your account is used first: on a card account the card is charged less, and on account it comes off your invoice. Your wholesale prices are GST-exclusive; the RRP is a retail price, so it includes GST.

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:

PrefixBehaviour
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.
EnvironmentsTest and live are separate environments. A test key is a safe sandbox: it reads the real catalogue and stock so you can build against real data, but it never creates orders and never shows live ones, so its order history stays empty until you go live. Your real orders are only ever visible to your live key.

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.

Keep it secretThe key is shown to us once when it's issued and stored only as a hash, so we can't read it back. Treat it like a password: server-side only, never in a browser or an app. Lost it? Ask us to rotate. The old key keeps working for a short overlap so you're never cut off mid-swap.

What a failed auth looks like

StatusMeaning
401The Authorization header is missing or malformed.
404The 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.

RuleDetail
correlationIdEvery 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.
PricesYour 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 valueA 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 inputsAn 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:

TypeNotes
stringText. 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.
integerA whole number, e.g. quantity, available, page.
decimalMoney: NZD, up to two decimals (e.g. 12.34). All prices are this type. Wholesale prices are GST-exclusive; rrp includes GST.
booleantrue / false.
datetimeISO 8601, UTC, with the Z suffix (e.g. 2026-09-03T02:10:00Z).
arraystring[] 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.

GET /ping

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": "…"
}
FieldTypeNotes
retailerstringYour company name.
environmentstring"test" or "live", matching your key.
entitledProductCountintegerHow many products GET /products will return you, discontinued ones included.
discontinuedProductCountintegerHow many of those are "discontinued".
serverTimeUtcdatetimeOur server clock, UTC.
paginationobjectdefaultPageSize (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.
limitsobjectYour own limits: requestsPerMinute, requestsPerDay, ordersPerDay, and maxSkusPerRequest (the most SKUs one /stock or /products?sku= call takes). See Rate limits.
GET /products

Your entitled catalogue, paged, including discontinued products (flagged). Wholesale prices are ex-GST; RRPs include GST.

ParameterTypeNotes
pageinteger1-based page number. Default 1.
pageSizeintegerDefault 50, capped at 200.
updatedSinceISO 8601 UTCOnly products that changed on/after this time: any detail we publish, or their stock band. For incremental sync: see Keeping in sync below.
statusstringOptional. active or discontinued. Blank returns both.
skustring, repeatableOptional. 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:

FieldTypeNotes
skustringNumeric, the product's variant id, as a string.
statusstring"active", or "discontinued": every supplier we buy it from has discontinued it. See the note below.
barcodestringEAN/UPC. Where a product has no barcode of its own, this is sometimes its SKU. "N/A" when we have none.
titlestringProduct name.
brandstring"N/A" when we have none.
descriptionstringHTML. Product dimensions are usually in here. "N/A" when we have none.
imagesstring[]Image URLs, in order: up to 12, every image we have for the product (may be empty).
wholesalePriceExGstdecimalYour price, ex-GST, NZD.
currencystringAlways "NZD".
gstIncludedbooleanAlways false: the wholesale price excludes GST.
rrpdecimal · nullableRecommended retail price, GST-inclusive, NZD. null when we don't have an RRP for the product.
rrpIncludesGstbooleanAlways true.
colourstring"N/A" when it doesn't apply.
sizestring"N/A" when it doesn't apply.
materialstring"N/A" when it doesn't apply.
flavourstring"N/A" when it doesn't apply.
widthinteger · nullablePackaging width in millimetres. The product's own size is usually in its description.
heightinteger · nullablePackaging height in millimetres.
depthinteger · nullablePackaging depth in millimetres.
weightinteger · nullablePacked weight in grams.
categorystringThe product's best single category: the first entry in categories. "N/A" when it has none.
categoriesobject[]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).
nextExpirydatetime · nullableExpiry date of the next batch to ship (the oldest in-stock batch). null for non-perishable items or when out of stock.
updatedAtUtcdatetime · nullableWhen 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).

Discontinued products stay in your catalogueA product discontinued by every supplier we buy it from keeps its place, with "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".
Keeping in syncDo one full pull, then ask for ?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.
GET /stock?sku=10432&sku=10433

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:

FieldTypeNotes
skustringThe SKU, as you sent it.
statusstring"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.
availableinteger · nullableA conservative floor, the bottom of the stock band we're in, never below 0. See the note below. null when status is "not_available".
asAtUtcdatetimeWhen this figure was read.
Availability is banded, not exactThe number you see is the lower bound of the stock band we're in (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.

POST /orders

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

FieldTypeNotes
clientOrderRefstring · reqYour order reference, the idempotency key. Up to 40 characters.
recipientNamestring · reqWho the parcel is addressed to.
recipientEmailstring · opt
recipientPhonestring · reqA contact number for the courier.
deliveryAddressobject · reqSee below.
linesobject[] · reqAt least one line; see below.
deliveryInstructionsstring · optFree text for the picker.
dryRunboolean · optDefault false. When true, validate only: nothing is created. You get back your prices and what you'd be charged (see Dry-run response).

deliveryAddress

FieldTypeNotes
address1string · req
address2string · opt
citystring · req
statestring · AU onlyRequired for Australian addresses.
postcodestring · req
countrystring · req"NZ" or "AU" (full names "New Zealand" / "Australia" also accepted): anything else is rejected.

lines[]

FieldTypeNotes
skustring · reqNumeric, the variant id, as a string.
quantityinteger · reqBetween 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

FieldTypeNotes
clientOrderRefstringYour reference, echoed back.
statusstring"Accepted" on create; the live status on a replay.
alreadyExistedbooleantrue if this ref had already been placed (a replay).
lines[].skustringThe variant id.
lines[].quantityinteger
lines[].unitPriceExGstdecimalYour unit price, ex-GST.
payment.methodstring"card" (charged up front) or "invoice" (invoiced on your account terms).
payment.amountChargeddecimal · nullableCard orders: the total charged, GST included, NZD. null for invoice orders.
payment.gstIncludeddecimal · nullableCard orders: the GST on the order (worked out on the full order, before any store credit). null for invoice orders.
payment.storeCreditApplieddecimal · nullableStore 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.shippingExGstdecimalThe 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.currencystringAlways "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" }
}
FieldTypeNotes
statusstring"dry_run", or "dry_run_test_key" on a test key.
dryRunbooleanAlways true.
wouldAcceptbooleantrue: the order would be accepted as it stands.
lines[].skustringThe variant id.
lines[].quantityinteger
lines[].availableintegerWhat you can order right now, as the same stock band GET /stock shows (0, 1, 5, 25 or 50).
lines[].unitPriceExGstdecimalYour unit price, ex-GST: what the real order would use.
paymentobject · nullableThe 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.
A preview, not a quoteStock, prices and your store credit can change between a dry run and the real order, so the real order's response is the one to keep.

Rejected line (in a 422 or 409)

FieldTypeNotes
skustring
quantityinteger
okbooleanWhether this line passed.
reasonstring · nullableunknown_sku, quantity_invalid, not_entitled, or insufficient_stock.
availableinteger · nullableWhat 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.
Stock moved mid-submissionIf stock moves between validating and submitting a live order, we reject the whole order with 409 stock_changed and the short lines, rather than ship you a partial order. Re-check stock and resubmit.
Paying by cardIf your account pays by card, a live order is charged to your saved card as part of placing it. We hold the full amount (goods and shipping, plus GST for NZ accounts), create the order, then take the payment. If anything fails along the way, the hold is released and you're not charged. If the card is declined you get 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.
Store creditIf we can't supply an item on a card-paid order, its value (plus GST for NZ accounts) goes onto your account as store credit when the order dispatches, and you'll see it in your wholesale account. (On account, you simply aren't invoiced for anything we couldn't supply.) Any store credit on your account is used automatically on your next order: it covers the goods (not shipping) and GST is still worked out on the full order. A card is then charged the rest, and on account it comes off your invoice. The 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, height and depth. 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 valueShippingGoods valueShipping
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 weightShippingParcel weightShipping
Under 1 kg$18.3416 – under 17 kg$102.11
1 – under 2 kg$23.5717 – under 18 kg$107.35
2 – under 3 kg$28.8118 – under 19 kg$112.59
3 – under 4 kg$34.0519 – under 20 kg$117.82
4 – under 5 kg$39.2820 – under 21 kg$123.06
5 – under 6 kg$44.5221 – under 22 kg$128.29
6 – under 7 kg$49.7522 – under 23 kg$133.53
7 – under 8 kg$54.9923 – under 24 kg$138.77
8 – under 9 kg$60.2324 – under 25 kg$144.00
9 – under 10 kg$65.4625 – under 26 kg$149.24
10 – under 11 kg$70.7026 – under 27 kg$154.47
11 – under 12 kg$75.9327 – under 28 kg$159.71
12 – under 13 kg$81.1728 – under 29 kg$164.95
13 – under 14 kg$86.4129 – under 30 kg$173.48
14 – under 15 kg$91.6430 kg and over$180.00
15 – under 16 kg$96.88
GST and store creditNZ accounts pay 15% GST on shipping, as on the goods. Store credit never covers shipping. On a card account, shipping is part of amountCharged. On account, it's the Shipping line on your invoice.
See it before you orderA dry run returns the order's exact shipping as 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.

GET /orders/{clientOrderRef}

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": "…" }
FieldTypeNotes
clientOrderRefstringYour reference.
statusstringAwaitingDispatch, Dispatched, or Cancelled.
carrierstring · nullableFills in at dispatch.
trackingNumberstring · nullableFills in at dispatch.
trackingUrlstring · nullableA ready-made link straight to the carrier's tracking page, no need to build it yourself. Fills in at dispatch, alongside trackingNumber.
dispatchedAtUtcdatetime · nullableWhen it left the warehouse.
placedAtUtcdatetimeWhen 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.

GET /orders

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.

Keep polling as your safety netWebhooks can be missed: your endpoint might be down, or a delivery might time out. Treat the webhook as the fast path and 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.

  1. Stand up an HTTPS endpoint. A public URL that accepts a POST with a JSON body and returns 2xx quickly. HTTP and internal/private addresses are refused.
  2. Send us the URL. Email it to your Wholesale Solutions account contact and ask to enable dispatch webhooks. We configure it against your account.
  3. 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.
  4. Verify a test event. We fire a webhook.test event at your endpoint; confirm you can validate its signature, and you're live.
POST → your endpoint

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:

HeaderNotes
X-WSL-Signaturet=<unix>,v1=<hex>, the timestamp and HMAC used to verify the call (below).
X-WSL-Event-IdThe event's id, repeated as a header. Use it to ignore a redelivered event you've already processed.
X-WSL-Event-Typeorder.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
  1. Compare constant-time. Check your expected equals v1 using a constant-time comparison, not ==.
  2. Reject stale calls. If t is more than a few minutes old, reject it: this stops a captured request being replayed.
  3. Sign the raw bytes. HMAC the body exactly as received, before any JSON re-serialisation: reformatting it changes the signature.
Respond fast, work asyncReturn 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.

StatusErrorWhat it means
400unknown_parameter · unknown_fieldA query param or body field we don't recognise, usually a typo.
400sku_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.
400invalid_query · invalid_status/stock was given both sku and changedSince (use one); or /products?status= wasn't active or discontinued.
401unauthorizedMissing or malformed Authorization header.
403https_requiredThe 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).
402card_requiredYour 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.
402payment_declinedThe 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.
404not_foundUnknown/revoked key, or a resource that isn't yours. We never confirm which.
409stock_changedStock moved between validating and creating: the order was rejected whole.
422validation_failedPer-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).
429too_many_requests · daily_limit_reachedYou'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.
413payload_too_largeThe request body is over 64 KB. A full 200-line order is well under 16 KB.
503service_unavailableThe API is temporarily switched off for maintenance. Retry later.
503payment_unavailableWe 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.

LimitNotes
Per minuteA 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 dayA daily request quota: 10,000 on the Standard tier, reported live in the X-RateLimit-* headers so you can pace yourself.
Orders per dayA 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.

  1. Ping with your test key. Call GET /ping: a name and an entitled count back means you're connected.
  2. Pull your catalogue. Page through GET /products and map SKUs, prices, and images into your system. Remember wholesale prices are ex-GST and RRPs include GST.
  3. Wire up stock. Read GET /stock?sku=… for the SKUs you list, then keep it current with GET /stock?changedSince=… on the cadence that suits you. Delist anything that comes back "not_available", or "discontinued" at 0.
  4. Dry-run an order. POST /orders with "dryRun": true (or just your test key). Check the prices, shipping and the payment preview it returns, and that you handle a 422.
  5. 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 through GET /orders/{ref} to Dispatched with tracking. Prefer a push over polling? Ask us to set up a webhook.
Stuck on a call?Send us the 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.shippingExGst on 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 unitPriceExGst and the payment block 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 from 0 to 1). Their stock band changed today, so your next /stock?changedSince= or updatedSince sync 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 /products takes ?status=active if you only want the rest.
  • Check your code GET /stock answers 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 showing 0). Every line now has a status.
  • Check your code Text fields with no value say "N/A" instead of null: barcode, brand, description, colour, size, material, flavour, category. Numbers and dates stay null.
  • Check your code rrp includes GST (the guide wrongly said ex-GST) and is null when we have no RRP, including the old $999.99 placeholders. New rrpIncludesGst, always true.
  • Check your code updatedAtUtc is 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. updatedSince now 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, as master › header › sub. category is now the best single one, rather than whichever came first. Names no longer contain HTML codes such as &#37;.
  • Up to 12 images per product (was 6).
  • 200 SKUs per call now works on /stock and /products?sku= (long lists were rejected before reaching us), and SKUs can be comma-separated.
  • The per-minute 429 is now our standard JSON error (too_many_requests), and Retry-After is the real number of seconds to wait.
  • GET /ping adds limits (your per-minute, per-day, orders-per-day and SKUs-per-call limits) and discontinuedProductCount.
  • The guide now documents width/height/depth/weight as packaging, and that a barcode is 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_declined and 503 payment_unavailable, and a payment block on a successful order.
  • Store credit on your account is used automatically on your next order (payment.storeCreditApplied).
  • available in dry-run, 422 and 409 order responses is banded, like /stock.
  • clientOrderRef is limited to 40 characters. New 422 reasons: 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.
  • entitledProductCount on /ping matches what /products returns.

15 September 2026

  • First release of v1: catalogue, stock, orders, order tracking, and dispatch webhooks.