Pickup locations
Lists the carrier pickup points available to a license. The points belong to the caller license's carrier: an HFD license returns HFD points, a Cargo license returns Cargo points, and so on.
v2 equivalent:
GET /pickup-points.
v2 does far more here
v1 has no filtering, no limit and no ordering — it returns the carrier's entire point list, every time, in carrier order. GET /pickup-points adds case-insensitive city filtering, a limit, and nearest-first ranking with a distance_km per point, given either explicit lat/lng or a free-text address it geocodes for you. It also returns a flat, carrier-agnostic list rather than v1's nested spots.spot_detail wrapper. If you are selecting a point for a customer to collect from, v2 is the endpoint you want.
POST /shipping/pickup_locations
Return the resolved license's carrier pickup points.
Parameters
Body
The handler takes no parameters of its own — its method signature does not even receive the request. The only fields it reads are the credentials the license middleware requires:
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | yes | The License key. The carrier, its credentials and the collection address are all resolved from it. |
domain | string | yes | The calling store's domain. Required by the middleware. |
token | string | no | Not read by the handler. The licence middleware reads it, when present, to resolve the acting user for the licence check. |
There is nothing to send for filtering, paging or ordering, and the handler ignores everything else in the body — a call that added city, limit, lat, lng, show_all, display_points and bogus_param returned a byte-identical response. The licence middleware is the exception: besides license_key and domain it also reads token.
What the list contains is instead decided by the license's own provider settings — most notably display_points: set to indoor, the Cargo and Done integrations drop outdoor points from the list. The Done integration additionally respects per-license activation, so a point an operator deactivated in the ShipOS admin does not appear.
Example request
curl --location 'https://app.shipos.co.il/api/shipping/pickup_locations' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"license_key": "{license_key}",
"domain": "your-store.example.com"
}'// Node.js 18+ / browsers — no dependencies
const response = await fetch('https://app.shipos.co.il/api/shipping/pickup_locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
license_key: process.env.SHIPOS_LICENSE_KEY,
domain: process.env.SHIPOS_DOMAIN,
}),
})
const body = await response.json()
if (!response.ok || !body.success) {
// 500 "Error Retrieving Locations" when the carrier has no pickup network
throw new Error(`ShipOS v1 pickup locations: ${body.message}`)
}
const points = body.data.locations.spots.spot_detail
for (const point of points) {
console.log(point.n_code, point.name, point.city)
}<?php
use Illuminate\Support\Facades\Http;
$body = Http::acceptJson()
->post('https://app.shipos.co.il/api/shipping/pickup_locations', [
'license_key' => config('services.shipos.license_key'),
'domain' => config('services.shipos.domain'),
])
->json();
if (! ($body['success'] ?? false)) {
throw new RuntimeException('ShipOS v1 pickup locations: '.$body['message']);
}
$points = $body['data']['locations']['spots']['spot_detail'];
foreach ($points as $point) {
logger()->info($point['n_code'].' '.$point['name'].' '.$point['city']);
}Example response
200:
{
"data": {
"locations": {
"spots": {
"spot_detail": [
{
"city": "תל אביב",
"name": "סופר פארם דיזנגוף",
"house": "50",
"n_code": "12345",
"street": "דיזנגוף",
"remarks": "",
"latitude": "32.0791",
"longitude": "34.7742",
"street_code": "50"
}
]
}
}
},
"message": "",
"success": true,
"warning": ""
}The list is not where you would expect it
The points are at data.locations.spots.spot_detail — not at data.locations. Every carrier integration normalises its own payload into that spots.spot_detail wrapper before returning it, which is why the shape looks like the XML of the carrier it was originally modelled on.
Pickup location fields
Every carrier maps its own payload onto these nine keys. A value the carrier does not supply comes back as an empty string, never null — and that holds literally: a live Cargo response contained zero null values anywhere across all 276 points, with house empty on all 276 and remarks empty on 3.
| Field | Type | Description |
|---|---|---|
n_code | string | The point's carrier identifier. This is the value that identifies the chosen point when a shipment is created — the creation path reads it as pickup.n_code. |
name | string | Point display name. |
city | string | City. |
street | string | Street. Some carriers put the whole address line here. |
house | string | House / street number. Empty for several carriers, which have no separate field for it. |
street_code | string | Carrier street code where one exists; some carriers reuse it for the house number. |
remarks | string | Free text from the carrier — opening hours, an English address, or a comment, depending on the carrier. |
latitude | string | Latitude as the carrier reported it. |
longitude | string | Longitude as the carrier reported it. |
Some carriers add extra keys on top of those nine. They are not part of a common contract — do not depend on them being present:
| Field | Type | Carriers | Description |
|---|---|---|---|
is_outdoor | bool | string | ZigZag, Cargo, Done, Baldar | Whether the point is an outdoor locker. The type really does vary by carrier: on a live Cargo licence it came back as an empty string on all 276 points, not a boolean. |
is_active | bool | Done | Whether the point is enabled for this license. |
storageSackId, routeName, routeAreaCode | string | Done, Baldar | Carrier routing metadata. |
Notes on behaviour
- Responses are large. The full carrier list is returned with no cap. A live Cargo call returned 276 points / 127,789 bytes (~125 KB). Fetch it once and cache it on your side rather than per checkout. Note too that the handler truncates its own
api_request_logscopy at 65,500 characters, so the logged response for a list this size is always incomplete. - The 60-minute per-license cache does not cover every carrier. Where the cache is used, its key includes the license's provider settings, so changing credentials or the collection address takes effect immediately instead of waiting out the TTL. Five carriers (Cargo, Done, HFD, Orian, Run Software) never touch that cache: they persist their list to ShipOS's own
pickup_locationstable and serve it from there, andpickup-locations:refreshis what refreshes those rows. The stored row is not the normalised list — it holds the carrier's raw payload (for Cargo, top-levelResult,errorCode,error_msgandPointsDetails) andtransformLocations()re-normalises all 276 points intospots.spot_detailon every request. That is why a pure database read still costs about 2.1 s and shows no cold/warm split: three consecutive live Cargo calls timed 2.19 s, 2.09 s and 2.16 s. - A carrier with no pickup network is an error, not an empty list. Confirmed live on two active licences: LionWheel and Self Pickup both return
HTTP 500with{"data":[],"message":"Error Retrieving Locations","success":false,"warning":""}. Note that this failure keeps the four-key envelope,warningincluded — it goes through the standard success helperapiResponse, not the three-keyfailResponseshape — sodatais[]rather than absent, and there is nodata.locationsat all.
Errors
| Status | message | When |
|---|---|---|
| 403 | Invalid License Key / License key has been deactivated | License check failed in the middleware. |
| 422 | — | license_key or domain missing. Laravel's validation shape ({"message": …, "errors": {…}}), not the v1 envelope. |
| 500 | Error Retrieving Locations | The carrier integration returned nothing — the carrier call failed, credentials are wrong, or the carrier has no pickup network at all (verified live on LionWheel and Self Pickup). data is [] and the four-key envelope is kept, so warning is present and empty. |