The REST API, and the connectors built on top of it. Every account includes a sandbox, so you can integrate end to end before a single real parcel moves.
Authenticate every request with your API key in the Authorization header. Your keys are on the API & Integrations page of the customer portal.
Authorization: Bearer wp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Your account has two environments. The key you use decides which one you're talking to — the URLs are identical.
| Environment | Key prefix | Behaviour |
|---|---|---|
| Live | wp_live_ | Real orders. Picked, packed and shipped by our warehouse. |
| Sandbox | wp_test_ | Test orders. Visible in the portal's sandbox mode, never fulfilled. |
Orders are separated; your catalogue is shared. A sandbox key can't see, create or cancel a live order, and a sandbox order never moves stock — so nothing you do with a test key reaches the warehouse floor.
Your products are not split between the two, on purpose: a SKU is a SKU, and sandbox orders need to
name your real ones to be worth testing against. So GET /stock returns
your real levels to a test key, and POST /products updates your real
catalogue. Worth knowing when you register products from a test key: the weights and sizes you send
are the ones we'll route your live parcels on.
Base URL: https://waypoint3pl.com/api/v1
1200 requests a minute per key, counted over a rolling minute. Your sandbox and live keys have separate allowances, so building an integration can't slow down the orders we're actually shipping for you.
Every answer tells you where you stand — you don't have to find the limit by hitting it:
X-RateLimit-Limit: 1200 X-RateLimit-Remaining: 1187 X-RateLimit-Reset: 1786475776 # unix time when the oldest call ages out
Over the limit you get 429 Too Many Requests with a Retry-After
header and the same figure in the body:
# 429 Too Many Requests { "error": "Rate limit of 1200 requests per minute exceeded on this key. Retry in 42s.", "retry_after": 42 }
429 won't extend
your own lockout. Waiting Retry-After seconds is always enough.Lists take ?limit= (up to 250, 100 by default) and return a next_cursor
to carry on with. Keep following it while has_more is true:
curl https://waypoint3pl.com/api/v1/orders?limit=100 \ -H "Authorization: Bearer wp_live_YOUR_KEY" # 200 OK { "data": [ … ], "has_more": true, "next_cursor": "MjAyNi0wOC0xMSAwOToxNDo0Mx8z" } # next page curl "https://waypoint3pl.com/api/v1/orders?limit=100&cursor=MjAyNi0wOC0xMSAwOToxNDo0Mx8z" \ -H "Authorization: Bearer wp_live_YOUR_KEY"
The cursor is opaque — pass it back as you got it, don't build one. It marks a position rather than counting rows, so a page can't shift underneath you when an order changes while you're walking the list.
updated_sinceTo find what's changed rather than what's newest, pass a timestamp. This is how you poll us:
curl "https://waypoint3pl.com/api/v1/orders?updated_since=2026-08-11T09:30:00Z&limit=100" \ -H "Authorization: Bearer wp_live_YOUR_KEY"
Orders come back oldest change first — the opposite of the default listing, and on purpose. Paging newest-first through a backlog hands you the most recent page and leaves the rest behind, which is the wrong end when you're catching up.
Walk next_cursor to the end, then keep the updated_at of the last order you
saw and use it as the next poll's updated_since. Nothing is missed if your job dies halfway: you simply
start again from the last one you actually recorded.
GET /stock is the exception.
With no ?limit= it returns your whole catalogue, as it always has — so an existing stock sync keeps working
unchanged. Paging it is opt-in.Create an order. We'll pick, pack and dispatch it (live), or simulate it (sandbox).
| Field | Type | Notes |
|---|---|---|
customer_name | string | Required. Your customer's name. |
external_ref | string | Strongly recommended. The order's id in your own system (#1042, 000000145, anything). Shown beneath our reference in your portal so you can reconcile, and used to prevent duplicates. Aliases: external_id, order_number. |
items | array | Required. What we're picking — at least one item. |
items[].name | string | Required. Product name as it should appear on the pick list. |
items[].sku | string | Your SKU. Strongly recommended — it's what our pickers scan. |
items[].quantity | integer | Defaults to 1. |
items[].unit_price | number | Price per unit, in the order currency. |
total | number | Order value. If omitted, we calculate it from item prices. |
currency | string | 3-letter code. Defaults to GBP. |
ship_policy | string | together (one parcel — default) or separate (items may ship as they're ready). |
destination.city | string | Delivery city. |
destination.country | string | Two-letter code or name. Defaults to UK. |
channel | string | Where the order came from: Shopify, Magento or Custom. Defaults to Custom. |
# Create a sandbox order curl -X POST https://waypoint3pl.com/api/v1/orders \ -H "Authorization: Bearer wp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "customer_name": "Jane Diaz", "external_ref": "#1042", "destination": { "line1": "14 Priory Gardens", "city": "Manchester", "postcode": "M20 2XJ", "country": "UK", "phone": "+44 7700 900123" }, "channel": "Shopify", "ship_policy": "together", "currency": "GBP", "items": [ { "name": "Lavender Soy Candle 220g", "sku": "LAV-220", "quantity": 2, "unit_price": 18.00 }, { "name": "Gift Box Sleeve", "sku": "PKG-GBS", "quantity": 1, "unit_price": 2.50 } ] }' # 201 Created { "order_ref": "TEST-1013", "external_ref": "#1042", "status": "Received", "environment": "sandbox", "customer_name": "Jane Diaz", "destination": { "name": "Jane Diaz", "line1": "14 Priory Gardens", "city": "Manchester", "postcode": "M20 2XJ", "country": "UK", "shippable": true }, "channel": "Shopify", "ship_policy": "together", "total": 38.5, "currency": "GBP", "items": [ { "name": "Lavender Soy Candle 220g", "sku": "LAV-220", "quantity": 2, "unit_price": 18.0 }, { "name": "Gift Box Sleeve", "sku": "PKG-GBS", "quantity": 1, "unit_price": 2.5 } ], "shipping": { "method": "Next Day Delivery", "speed": "next_day", "weight_kg": 1.25 }, "courier": null, "tracking_number": null, "tracking_url": null, "cancellable": true, "received_at": "2026-07-31 12:04:11", "updated_at": "2026-07-31 12:04:11" }
Send the full address — we can't put a parcel on a van without it. line1, city and postcode are what we actually need; the rest helps.
| Field | Meaning |
|---|---|
destination.line1 * | Street address. Also accepted as address1 or street. |
destination.line2 | Flat, unit, building. Also accepted as address2. |
destination.city * | Town or city. |
destination.postcode * | Postcode or ZIP. Also accepted as zip or postal_code. |
destination.region | County, state or province. Also accepted as state or county. |
destination.country | Country name or 2-letter code. Defaults to UK. |
destination.company | Company name, for business deliveries. |
destination.phone | Contact number. Couriers ask for this on most express services. |
destination.email | Used only if a courier needs to contact the recipient. |
* required before we can ship. We still accept an order without them — better in the system than rejected at the door — but the response comes back with address_warnings and destination.shippable: false, and the order is flagged in the portal until it's fixed. Check for that field while you still have the shopper's details to hand.
{
"destination": {
"name": "Jane Diaz",
"line1": "14 Priory Gardens",
"line2": "Flat 2",
"city": "Manchester",
"region": "Greater Manchester",
"postcode": "M20 2XJ",
"country": "UK",
"phone": "+44 7700 900123"
}
}
The recipient's name comes from customer_name, so you don't need to repeat it in the address.
All optional, but the more you send the better we can pick the right service.
| Field | Meaning |
|---|---|
shipping.method_code | Your platform's raw code for that option — flatrate_flatrate. This is the one that matters: we match it against your mapping table to pick the actual courier service. Map your codes under Shipping in the portal. |
shipping.method | Whatever the shopper picked at checkout, in your own words — "Next Day Delivery". Shown to our warehouse team as-is. |
shipping.speed | Normalised so we can sort the pick queue: economy, standard, express, next_day or same_day. |
shipping.weight_kg | Parcel weight in kilograms, if you know it. |
ship_policy | together (one parcel) or separate (items may ship as they're ready). |
group_ref | Set this when you split one basket across several orders — see below. |
You map your checkout codes to our services once, in the portal under Shipping, and from then on every order that carries a shipping.method_code is matched automatically. Anything unmapped uses your fallback service.
A code maps to an ordered list of services, not just one. We work down it and send the order with the first that can actually carry the parcel — so if your first choice stops at 15 kg and the order weighs 18 kg, it moves to your next choice instead of stalling. List them in the order you'd like them used.
The order response tells you what we picked and what it will cost, so you can reconcile against what the shopper paid:
# In any order response "shipping": { "method": "Next Day Delivery", "method_code": "dpd_nextday", "quoted_service": "DPD Next Day", // what your mapping chose "quoted_cost": 7.48, // what you'll be billed "service": "DPD Next Day", // what it actually went with "cost": 7.48 }
When we drop past your first choice because of weight, the create response says so in shipping_notes. That isn't a fault — it's the list doing its job — but the price changes, so you hear about it while the order is still in front of you:
# 201 Created — an 18kg order on a code led by a 15kg service "shipping_notes": [ "This order is around 18kg. Evri Standard only carries 15kg, so it's going with your next choice, DPD Two Day." ]
shipping_warnings is the real problem case: we couldn't map the code at all, or nothing in your list (and no fallback) can carry the parcel. We still accept the order and pick a service by hand, but it will be slower — add a heavier alternative to avoid the delay.
Every service we can offer, what each costs you, and how your codes currently map. Read-only — use it to check what a code will resolve to before you send orders, or to show your team the options. Mappings themselves are set in the Shipping page of the portal.
curl https://waypoint3pl.com/api/v1/shipping/services \ -H "Authorization: Bearer wp_live_YOUR_KEY" # 200 OK { "currency": "GBP", "data": [ { "code": "dpd-next-day", "name": "DPD Next Day", "courier": "DPD", "speed": "next_day", "max_weight_kg": 30, "price": 7.48 } ], // Each code lists its services in the order we'll try them "mappings": [ { "method_code": "flatrate_flatrate", "services": [ { "service_code": "evri-standard", "service_name": "Evri Standard", "courier": "Evri", "max_weight_kg": 15 }, { "service_code": "dpd-two-day", "service_name": "DPD Two Day", "courier": "DPD", "max_weight_kg": 30 } ] } ], "default_service": { "code": "evri-standard", "name": "Evri Standard"} }
weight_kg with the order (or on your products, below) and we'll walk your list and pick something that can actually carry the parcel. Without a weight we can only take your first choice, so send it if you have it.If some items ship separately, send each part as its own order with its own external_ref, and put the shopper's original order number in group_ref on every part. They stay one purchase in the portal, and you can fetch them all back with GET /api/v1/orders?group_ref=1000123.
# Part 1 of order 1000123 { "external_ref": "1000123-1", "group_ref": "1000123", "ship_policy": "separate", … } # Part 2 of order 1000123 { "external_ref": "1000123-2", "group_ref": "1000123", "ship_policy": "separate", … }
List orders in the key's environment, newest first.
curl https://waypoint3pl.com/api/v1/orders \ -H "Authorization: Bearer wp_live_YOUR_KEY" # 200 OK { "environment": "live", "data": [ { "order_ref": "WP-84183", … } ], "has_more": false, "next_cursor": null }
| Parameter | What it does |
|---|---|
limit | Orders per page, up to 250. Defaults to 100. |
cursor | next_cursor from the previous page. See Paging & syncing. |
updated_since | Only orders changed since this timestamp, oldest change first. How you poll for updates. |
group_ref | Every part of a split order, by the original order number you sent. |
Fetch a single order — poll this to show live status, courier and tracking inside your own systems. {ref} can be either our order_ref or your own external_ref, so you can look an order up with whichever id you have to hand. URL-encode it if it contains characters like #.
# By our reference curl https://waypoint3pl.com/api/v1/orders/WP-84183 \ -H "Authorization: Bearer wp_live_YOUR_KEY" # By your own reference (#1042, url-encoded) curl https://waypoint3pl.com/api/v1/orders/%231042 \ -H "Authorization: Bearer wp_live_YOUR_KEY"
Order status moves through: Received → Picking → Picked → Packing → Packed → Labelling → Dispatched → In transit → Delivered. Once dispatched, courier, tracking_number and tracking_url are populated. An order can also end at Cancelled or Returned.
Fetching a single order also returns its timeline, so you can show a shopper exactly when each step happened without storing every poll yourself:
"events": [ { "status": "Received", "at": "2026-07-31 09:02:11" }, { "status": "Picking", "at": "2026-07-31 10:14:03" }, { "status": "Dispatched", "at": "2026-07-31 16:40:52" } ]
events is only on the single-order endpoint — the list would need a query per order. Poll the list for changes, then fetch the one that moved.Returns are counted per line, because most aren't all-or-nothing — a shopper sends one of three items back. Every item carries returned_quantity, so you can refund exactly what came back:
"status": "Partially returned", "partial_return": true, "items": [ { "sku": "LAV-220", "quantity": 3, "returned_quantity": 2, "unit_price": 18.00 }, { "sku": "PKG-GBS", "quantity": 1, "returned_quantity": 0, "unit_price": 2.50 } ]
| Status | Meaning |
|---|---|
Partially returned | Some of the order is back. Refund the returned lines only — more may follow, so keep polling. |
Returned | Everything is back. Safe to refund the order in full, shipping included. |
returned_quantity is a running total, not the latest batch. If it goes from 1 to 2, one more unit came back — refund the difference, not the whole figure. A partial return keeps the same status when more arrives, so compare quantities rather than waiting for the status to change.
available stock as soon as we book them in, so your storefront can resell them without waiting for the refund to clear.Once we've packed and weighed it, every order carries the parcel's real weight and box size, plus what the delivery cost you:
"package": { "weight_kg": 2.65, "length_cm": 30, "width_cm": 20, "height_cm": 12 }, "shipping": { "service": "DPD Two Day", "cost": 6.79, … }
external_ref twice and we won't create a second order — you'll get 200 with the original and "duplicate": true, rather than 201. So a webhook that fires twice can't put a parcel out twice.Stop an order before it leaves the warehouse. Like the fetch endpoint, {ref} can be our reference or your own. Anything reserved for the order goes straight back to available stock.
curl -X POST https://waypoint3pl.com/api/v1/orders/1000123/cancel \ -H "Authorization: Bearer wp_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "Cancelled by the customer" }' # 200 OK { "order_ref": "WP-84183", "status": "Cancelled", "cancellable": false, … }
Orders can be cancelled while they are Received, Picking, Picked, Packing, Packed or Labelling. Once we've handed the parcel to a courier it's too late, and you get 409 Conflict — with the courier and tracking details so you can tell the customer where their parcel actually is:
# 409 Conflict { "error": "Order WP-84183 can no longer be cancelled — it is already \"Dispatched\". Raise a return instead.", "status": "Dispatched", "cancellable": false, "courier": "DPD", "tracking_number": "DPD123456789", "tracking_url": "https://track.dpd.co.uk/…" }
200 with "already_cancelled": true, never an error.Check cancellable on any order response to know whether the button should still be live in your own UI.
We track stock per SKU as goods arrive and orders are picked. Pull these figures back into your store so you never sell what we can't ship.
| Field | Meaning |
|---|---|
name | Mirrors whatever you last sent, so a SKU reads the same in your admin, on our pick lists and on the shelf. It isn't editable in the portal — every catalogue push overwrites it, which is what keeps the two sides in step. |
on_hand | Physically on our shelves. |
allocated | Already promised to orders we haven't dispatched. |
available | on_hand − allocated. This is the number to publish on your storefront. |
low_stock_level | We flag the SKU as Low stock when available falls to this number or below, so you know to reorder. 0 = never flag. A SKU we haven't seen before starts at 30; leave the field out on a later push and we keep whatever is already set, so re-sending your catalogue never wipes a level you chose. |
ever_stocked | Read this before you mirror our figures onto your shop. false until the goods physically reach us. A SKU you've registered but we've never counted has an available of 0 — and that 0 means "we don't know yet", not "there are none". Skip those SKUs rather than writing 0 over a shelf that is actually full. |
status | In stock, Low stock, Out of stock, or Never stocked when we've been told about the SKU but never received any. |
Every SKU we hold for you. Poll this on a schedule (every few minutes is plenty) to keep your storefront in step.
curl https://waypoint3pl.com/api/v1/stock \ -H "Authorization: Bearer wp_live_YOUR_KEY" # 200 OK { "as_of": "2026-07-31 14:22:08", "data": [ { "sku": "LAV-220", "name": "Lavender Soy Candle 220g", "on_hand": 180, "allocated": 3, "available": 177, "low_stock_level": 40, "status": "In stock", "updated_at": "2026-07-31 14:04:11" } ] }
One SKU — handy for a just-in-time check at checkout.
curl https://waypoint3pl.com/api/v1/stock/LAV-220 \
-H "Authorization: Bearer wp_live_YOUR_KEY"
What we hold about each SKU, separate from how many of it are on the shelf. Stock answers how many can I sell?; this answers which of my SKUs does the warehouse know about, and what has it measured?
Your catalogue, by SKU. Paged like every other list — ?limit= and ?cursor=.
curl https://waypoint3pl.com/api/v1/products \ -H "Authorization: Bearer wp_live_YOUR_KEY" # 200 OK { "data": [ { "sku": "LAV-220", "name": "Lavender Soy Candle 220g", "barcode": "5012345678900", "low_stock_level": 40, "weight_kg": 0.42, "dimensions_cm": { "length": 12, "width": 8, "height": 8 }, "ever_stocked": true, "created_at": "2026-07-14 09:11:02", "updated_at": "2026-08-02 16:40:55" } ], "has_more": false, "next_cursor": null }
GET /stock, whose numbers move every time a parcel goes out.Register a SKU up front, or update its name, low-stock level, weight and size. Sending the same SKU again updates it rather than creating a duplicate.
curl -X POST https://waypoint3pl.com/api/v1/products \ -H "Authorization: Bearer wp_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "sku": "LAV-220", "name": "Lavender Soy Candle 220g", "low_stock_level": 40, "weight_kg": 0.42, "dimensions_cm": { "length": 12, "width": 8, "height": 8 } }'
GET /api/v1/stock. Omitting a field never clears what we already hold, so a nightly catalogue push can't wipe our measurements.stock_warnings array on the response — so you can flag a backorder immediately.Errors return an appropriate HTTP status and a JSON body with an error message.
| Status | Meaning |
|---|---|
401 | Missing or invalid API key. |
400 | Request body isn't valid JSON. |
422 | Valid JSON, but a required field is missing (the message says which). |
404 | Unknown endpoint, or no order with that reference in this environment. |
405 | HTTP method not supported on this endpoint. |
409 | The order has already shipped, so it can no longer be cancelled. |
429 | Over your rate limit. Wait Retry-After seconds and repeat the call. |
Your keys, and the connector downloads, are on the API & Integrations page of your account. Not signed up yet? Tell us what you ship and we will get you set up.