Skip to content

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}/label and POST /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 ​

EndpointSelects bydata shapeCarrier label possible
POST /shipping/labelone shipping_id (the carrier tracking code){ "url": "…" }yes
POST /shipping/order-labelone order_id, expanded to every active shipment on it{ "labels": [ { "shipping_code": "…", "url": "…" } ] }yes
POST /shipping/bulk-labelup to 100 shipping_codes{ "url": "…" } — one URL for the whole setno, 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 is https://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 than bulk-label and 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: 3 produces 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:

FieldTypeRequiredDescription
outputstringnohtml renders the labels as an HTML page instead of a PDF. Any other value (or omitted) returns the PDF.
positionintegernoFor 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_warehousestringnotrue orders the labels by warehouse location.
sort_by_warehousestringnoDirection 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 ​

FieldTypeRequiredDescription
license_keystringyesThe License key. Verified by the license middleware.
domainstringyesThe calling store's domain. Required by the middleware.
shipping_idstringyesThe carrier tracking code (shippings.shipping_code). Not the uuid.
tokenstringnoA 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 ​

bash
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"
}'
js
// 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
<?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:

json
{
  "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:

json
{
  "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 HTTP 404: {"message": "No query results for model [App\\Models\\Shipping]."}. There is no success key.
  • Carrier returned no label — the carrier call produced an empty URL and the response is HTTP 200 with a bare {"error": "Invalid response from shipping company"}. No success, no data. A client that only reads success will 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 ​

FieldTypeRequiredDescription
license_keystringyesThe License key.
domainstringyesThe calling store's domain.
order_idstringyesThe 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 ​

bash
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"
}'
js
// 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
<?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:

json
{
  "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 ​

StatusmessageWhen
403Invalid License Key / License key has been deactivatedLicense check failed in the middleware.
404Order not foundNo order matching order_id (or order_number) under the resolved license.
404No active shippings found for this orderThe order exists but has no shipment with is_active = true.
500Invalid response from shipping companyEvery 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 ​

FieldTypeRequiredDescription
license_keystringyesThe License key.
domainstringyesThe calling store's domain.
shipping_codesarrayyesCarrier tracking codes. Min 1, max 100.
shipping_codes.*stringyesA shippings.shipping_code.
tokenstringnoA 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 ​

bash
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"]
}'
js
// 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
<?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:

json
{
  "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 ​

StatusBodyWhen
403v1 envelope, Invalid License Key / License key has been deactivatedLicense 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.