Labels
Fetch the printable label for a shipment. v1 has three label endpoints — one for a single shipment, one for every active shipment on an order, and one for an arbitrary set — and all three answer with a URL in the envelope, never with the PDF itself.
v2 equivalent:
GET /shipments/{shipment}/labelandPOST /shipments/labels.
All three sit in the shipping prefix group, so they carry their credentials in the payload (license_key + domain — see Authentication) and answer in the standard four-key v1 envelope.
The English message strings quoted on this page are translation keys
Every human-readable message here — Invalid License Key, Order not found, No active shippings found for this order, Invalid response from shipping company — is a translation key, not a wire value. A live server rendered them all in Hebrew. Match on the HTTP status, on success, and on which keys are present (data.url, data.labels, a bare error); never on the text of message.
Which endpoint to use
| Endpoint | Selects by | data shape | Carrier label possible |
|---|---|---|---|
POST /shipping/label | one shipping_id (the carrier tracking code) | { "url": "…" } | yes |
POST /shipping/order-label | one order_id, expanded to every active shipment on it | { "labels": [ { "shipping_code": "…", "url": "…" } ] } | yes |
POST /shipping/bulk-label | up to 100 shipping_codes | { "url": "…" } — one URL for the whole set | no, always the ShipOS renderer |
Only order-label honours is_active
order-label filters to active shipments and answers 404 / No active shippings found for this order when none are left. label and bulk-label apply no such filter. After a shipment was cancelled through change_order_status, both still returned a working label URL for it — HTTP 200, success: true — and the URL still rendered the label. Nothing in either response says the shipment is cancelled.
A label URL is therefore not evidence that a shipment is live. If that matters, check is_active yourself via POST /shipping/order before printing, or use order-label — the only one of the three that will refuse.
The response is a URL, not a document
No v1 label endpoint returns a PDF, a base64 blob, or a binary body. Each returns a url string that you then fetch to obtain the document. Those label URLs are on the app's web routes and are not authenticated — anyone holding one can print the label, so treat them as secrets and do not embed them in customer-facing pages.
What the URL points at
Which URL you get depends on the shipment's effective label method — the license's label_method provider setting, falling back to the customer account's label_method:
shipos— ShipOS renders the label itself. The URL ishttps://app.shipos.co.il/shipping/label/{uuid}, which redirects to the renderer and streams an inline PDF (generated with mPDF). No carrier call is made while building the response.- anything else — the carrier's own label is used. The backend calls the carrier's label API right there in the request, and the URL is whatever that call yields. For most carriers ShipOS downloads the carrier PDF once and returns its own copy at
https://app.shipos.co.il/storage/document/{uuid}.pdf; a few carriers return their own hosted link instead. This is a synchronous outbound carrier call, so these two endpoints are slower thanbulk-labeland can fail with the carrier.
POST /shipping/bulk-label never consults the label method: it always returns a shipping/labels URL served by the ShipOS renderer.
Paper size and format
None of the three endpoints accepts a paper-size or format parameter. The ShipOS renderer decides per shipment from the effective label type — the license's label_type provider setting, falling back to the customer's:
A4— laid out in an 8-up grid on A4 pages.- anything else (the default, thermal) — one 74 mm-wide page per package, with the page height computed from the rendered content. A shipment with
packages: 3produces three pages.
A mixed set is fine: a single PDF carries both, each shipment in its own format.
The rendered label URL does accept a few query parameters, which you can append to the url you got back before fetching it. They apply only to the ShipOS renderer (shipping/label/{uuid} and shipping/labels), not to a carrier-hosted URL:
| Field | Type | Required | Description |
|---|---|---|---|
output | string | no | html renders the labels as an HTML page instead of a PDF. Any other value (or omitted) returns the PDF. |
position | integer | no | For A4, the 1-based slot on the sheet to start printing at, so a partially used sticker sheet can be reused. position - 1 slots are left blank. |
enable_sort_by_warehouse | string | no | true orders the labels by warehouse location. |
sort_by_warehouse | string | no | Direction for that ordering, asc (default) or desc. |
POST /shipping/label
Return the label URL for one shipment, addressed by its carrier tracking code.
Parameters
Body
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | yes | The License key. Verified by the license middleware. |
domain | string | yes | The calling store's domain. Required by the middleware. |
shipping_id | string | yes | The carrier tracking code (shippings.shipping_code). Not the uuid. |
token | string | no | A Sanctum personal access token. When supplied and valid, the lookup widens from the single license to every license the token's user owns. |
The shipment must belong to the resolved license — matched either on the shipment's own license_id or on its order's license_id. is_active is not part of the lookup: a cancelled shipment still resolves and still gets a working label URL.
Example request
curl --location 'https://app.shipos.co.il/api/shipping/label' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"license_key": "{license_key}",
"domain": "your-store.example.com",
"shipping_id": "66747921"
}'// Node.js 18+ / browsers — no dependencies
const response = await fetch('https://app.shipos.co.il/api/shipping/label', {
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,
shipping_id: '66747921',
}),
})
const body = await response.json()
// A carrier that returned no label answers with a bare { error } object,
// so check for it before reading the envelope.
if (body.error) {
throw new Error(`ShipOS v1 label: ${body.error}`)
}
if (!response.ok || !body.success) {
throw new Error(`ShipOS v1 label: ${body.message}`)
}
console.log(body.data.url) // fetch this URL for the PDF<?php
use Illuminate\Support\Facades\Http;
$body = Http::acceptJson()
->post('https://app.shipos.co.il/api/shipping/label', [
'license_key' => config('services.shipos.license_key'),
'domain' => config('services.shipos.domain'),
'shipping_id' => '66747921',
])
->json();
if (isset($body['error'])) {
throw new RuntimeException('ShipOS v1 label: '.$body['error']);
}
if (! ($body['success'] ?? false)) {
throw new RuntimeException('ShipOS v1 label: '.$body['message']);
}
logger()->info('Label URL', ['url' => $body['data']['url']]);Example response
200, ShipOS-rendered label:
{
"data": {
"url": "https://app.shipos.co.il/shipping/label/9b2c7f1e-3a4d-4c88-9f21-2f0d5a6b7c88"
},
"message": "",
"success": true,
"warning": ""
}200, carrier label downloaded and re-served by ShipOS:
{
"data": {
"url": "https://app.shipos.co.il/storage/document/9b2c7f1e-3a4d-4c88-9f21-2f0d5a6b7c88.pdf"
},
"message": "",
"success": true,
"warning": ""
}Two non-envelope failure shapes
This endpoint can answer outside the four-key envelope:
- No such shipment (or one not owned by the resolved license) — the lookup uses
firstOrFail(), so the response is Laravel's model-not-found JSON at HTTP404:{"message": "No query results for model [App\\Models\\Shipping]."}. There is nosuccesskey. - Carrier returned no label — the carrier call produced an empty URL and the response is HTTP
200with a bare{"error": "Invalid response from shipping company"}. Nosuccess, nodata. A client that only readssuccesswill treat this as a failure by default (the key is absent), but must not treat it as a URL.
shipping_id is not validated, so a missing or empty one falls through to the 404 above rather than a 422.
POST /shipping/order-label
Return one label URL per active shipment on an order — the endpoint to call when an order was split into several packages or shipments and you want to print all of them.
Parameters
Body
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | yes | The License key. |
domain | string | yes | The calling store's domain. |
order_id | string | yes | The store's order identifier. Matched against either orders.order_id or orders.order_number, so the platform id and the human-facing number both work. The most recent match wins. |
The order must belong to the resolved license, or be an order with no license that belongs to the license's customer. Unlike the other two endpoints, order-label does not honour a token field — the lookup is always scoped to the single resolved license.
Only shipments with is_active = true are included. Cancelled shipments are skipped.
Example request
curl --location 'https://app.shipos.co.il/api/shipping/order-label' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"license_key": "{license_key}",
"domain": "your-store.example.com",
"order_id": "19602"
}'// Node.js 18+ / browsers — no dependencies
const response = await fetch('https://app.shipos.co.il/api/shipping/order-label', {
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,
order_id: '19602',
}),
})
const body = await response.json()
if (!response.ok || !body.success) {
// 404 "Order not found" / "No active shippings found for this order"
throw new Error(`ShipOS v1 order labels: ${body.message}`)
}
for (const label of body.data.labels) {
console.log(label.shipping_code, label.url)
}<?php
use Illuminate\Support\Facades\Http;
$body = Http::acceptJson()
->post('https://app.shipos.co.il/api/shipping/order-label', [
'license_key' => config('services.shipos.license_key'),
'domain' => config('services.shipos.domain'),
'order_id' => '19602',
])
->json();
if (! ($body['success'] ?? false)) {
throw new RuntimeException('ShipOS v1 order labels: '.$body['message']);
}
foreach ($body['data']['labels'] as $label) {
logger()->info($label['shipping_code'].' → '.$label['url']);
}Example response
200:
{
"data": {
"labels": [
{
"shipping_code": "66747921",
"url": "https://app.shipos.co.il/shipping/label/9b2c7f1e-3a4d-4c88-9f21-2f0d5a6b7c88"
},
{
"shipping_code": "66747922",
"url": "https://app.shipos.co.il/storage/document/1d5e8a2c-9f0b-4e11-8c33-6a7b8c9d0e12.pdf"
}
]
},
"message": "",
"success": true,
"warning": ""
}Each entry is resolved independently, so a mixed list is normal: one shipment can carry a ShipOS-rendered URL and the next a carrier one, depending on each shipment's license.
Partial results are silent
A shipment whose label resolution produced an empty URL is dropped from the array without a warning — warning stays empty and success stays true. Compare labels.length against the number of shipments you expect rather than assuming the list is complete. Only when every shipment fails does the endpoint report a failure.
Errors
| Status | message | When |
|---|---|---|
| 403 | Invalid License Key / License key has been deactivated | License check failed in the middleware. |
| 404 | Order not found | No order matching order_id (or order_number) under the resolved license. |
| 404 | No active shippings found for this order | The order exists but has no shipment with is_active = true. |
| 500 | Invalid response from shipping company | Every shipment's label resolution returned an empty URL. |
A missing order_id is a Laravel validation failure: HTTP 422 with {"message": …, "errors": {"order_id": […]}}, not the v1 envelope.
POST /shipping/bulk-label
Return a single URL that prints many shipments as one document. This is the cheapest of the three: it makes no carrier calls at all, it only resolves the codes you sent to uuids and builds the renderer URL.
Parameters
Body
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | yes | The License key. |
domain | string | yes | The calling store's domain. |
shipping_codes | array | yes | Carrier tracking codes. Min 1, max 100. |
shipping_codes.* | string | yes | A shippings.shipping_code. |
token | string | no | A Sanctum personal access token. When supplied and valid, the lookup widens to every license the token's user owns. |
Unknown codes are dropped silently
Codes that do not resolve to a shipment under the resolved license are simply left out of the URL, and the response is still 200 with success: true. Cancelled shipments are not dropped — bulk-label applies no is_active filter.
If none of them resolve you get a bare https://app.shipos.co.il/shipping/labels with no query string at all. That URL does not fail when opened: fetched, it redirects to the login page and returns HTTP 200 with an HTML body. A client that checks only the status code sees 200 and no PDF, and one that writes the body to a .pdf file writes a login page into it. Count the ids in the returned URL — or check that the fetched document's Content-Type really is application/pdf — if you need to know that everything you asked for was included.
Example request
curl --location 'https://app.shipos.co.il/api/shipping/bulk-label' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"license_key": "{license_key}",
"domain": "your-store.example.com",
"shipping_codes": ["66747921", "66747922"]
}'// Node.js 18+ / browsers — no dependencies
const shippingCodes = ['66747921', '66747922']
const response = await fetch('https://app.shipos.co.il/api/shipping/bulk-label', {
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,
shipping_codes: shippingCodes,
}),
})
const body = await response.json()
if (!response.ok || !body.success) {
throw new Error(`ShipOS v1 bulk label: ${body.message}`)
}
// Codes that did not resolve are absent from the URL, so verify the count.
const included = new URL(body.data.url).searchParams
const found = [...included.keys()].filter((key) => key.startsWith('ids[')).length
if (found !== shippingCodes.length) {
console.warn(`Only ${found} of ${shippingCodes.length} shipments were included`)
}
console.log(body.data.url)<?php
use Illuminate\Support\Facades\Http;
$shippingCodes = ['66747921', '66747922'];
$body = Http::acceptJson()
->post('https://app.shipos.co.il/api/shipping/bulk-label', [
'license_key' => config('services.shipos.license_key'),
'domain' => config('services.shipos.domain'),
'shipping_codes' => $shippingCodes,
])
->json();
if (! ($body['success'] ?? false)) {
throw new RuntimeException('ShipOS v1 bulk label: '.$body['message']);
}
parse_str((string) parse_url($body['data']['url'], PHP_URL_QUERY), $query);
if (count($query['ids'] ?? []) !== count($shippingCodes)) {
logger()->warning('Not every shipment was included in the bulk label URL');
}
logger()->info('Bulk label URL', ['url' => $body['data']['url']]);Example response
200:
{
"data": {
"url": "https://app.shipos.co.il/shipping/labels?ids%5B0%5D=9b2c7f1e-3a4d-4c88-9f21-2f0d5a6b7c88&ids%5B1%5D=1d5e8a2c-9f0b-4e11-8c33-6a7b8c9d0e12"
},
"message": "",
"success": true,
"warning": ""
}The ids in the URL are shipment uuids, not the shipping_codes you sent — the endpoint's one job is that translation. Fetching the URL streams one inline PDF containing every label, ordered oldest shipment first.
Errors
| Status | Body | When |
|---|---|---|
| 403 | v1 envelope, Invalid License Key / License key has been deactivated | License check failed in the middleware. |
| 422 | {"message": …, "errors": {"shipping_codes": […]}} | shipping_codes missing, not an array, empty, or longer than 100. This is Laravel's validation shape, not the v1 envelope. |