Skip to content

Shipments ​

The legacy v1 shipment endpoints: create a shipment with the carrier, read its details and status, cancel it, and list the shipments already created for a set of orders. These are the endpoints the WooCommerce plugin and the older direct integrations call. They are frozen — they receive fixes, not features.

Every endpoint on this page is a POST under https://app.shipos.co.il/api, takes its credentials in the request body rather than in headers, and answers with the v1 envelope described below. The v2 equivalent of this page is Shipments; new integrations should use it.

Two different shipping_id values

open_new_order returns data.shipping_id — our internal numeric row id. The shipping_id parameter taken by get_order_details, get_shipping_status and change_order_status is the carrier tracking code (shippings.shipping_code, returned as data.code). Passing the numeric id to those three endpoints returns 404.


Authentication ​

The five endpoints on this page that sit under the shipping prefix run the hasLicense middleware, which authenticates the license, not a user. There are no X-Client-Id / X-Client-Secret headers in v1.

FieldTypeRequiredDescription
license_keystringYeslicenses.key — the carrier account the call acts under.
domainstringYesThe calling storefront domain. Must be present and a string; the comparison against the license's own domain is commented out in CheckLicense, so any non-empty string satisfies it today.
tokenstringNoA Sanctum personal access token. When present the token's user is used for the premium-company check, and (on open_new_order) to resolve which connected WooCommerce site the order belongs to.

The license must exist and be active. If the license's company is marked premium, the call additionally needs a token whose user has an active premium subscription.

TIP

license_key and domain are validated with Laravel's own validator, so a missing one produces Laravel's default 422 body ({ "message": ..., "errors": {...} }), not the v1 envelope. Every other hasLicense rejection is 403 in the v1 envelope — including "license not found", which the underlying check labels 404 but the middleware always returns as 403.

There is also a first-party bypass: a request carrying the application key as secret skips the domain/premium checks entirely. It is not available to integrators.


The v1 envelope ​

Every handler on this page except POST /shipping/order builds its body with the apiResponse() helper:

json
{
  "data": {},
  "message": "",
  "success": true,
  "warning": ""
}
FieldTypeDescription
datamixedThe payload. Shape differs per endpoint — object, array, model row, or null.
messagestringHuman-readable outcome. On carrier failures this is the carrier's own message, verbatim (often Hebrew), not a ShipOS string.
successbooleanWhether the operation succeeded.
warningstringReserved; empty on every endpoint on this page.

success and the HTTP status do not always agree

apiResponse() defaults to HTTP 200, and two paths rely on that default: change_order_status returns a failed cancellation as HTTP 200 with success: false, and get_order_details returns a failed carrier lookup as HTTP 200 with success: true and the failure nested inside data. Branch on the body, not on the status code alone.

Every English message quoted on this page is a translation key, not a wire value

The strings in this page are the keys as they appear in the source. message is rendered through the translation layer, so what actually arrives on the wire follows the server's locale. A live server returned Hebrew for all of them: the validation messages, Something went wrong as אירעה שגיאה, Shipping status successfully updated as סטטוס המשלוח עודכן בהצלחה, and the appended shipping_type label as משלוח רגיל.

Never string-match on message. Branch on the HTTP status, on success, and on the presence of field keys. Use message for display and for logs only.

The whole group also runs the normalize_shipment_response middleware. It does one thing to the body: when data is an array containing a top-level code key, that value is cast to a string. Nested code keys are left alone — a code sitting at data.data.code, which is where get_order_details puts it, is not cast and comes back exactly as the carrier sent it (observed live: the integer 2). Carriers are inconsistent (LionWheel returns an integer task id), and strictly typed clients broke on the bare integer. Everything else is passed through byte-identically, Hebrew included. The middleware additionally records the exact bytes sent back in api_request_logs.normalized_response, truncated to 60 000 bytes; that persistence is best-effort and can never fail your request.

POST /shipping/{shipment}/status is registered outside this group and is normalized by nothing — see its own section.


Shipment types ​

The service a shipment gets is encoded in two ship_data fields, type and return. Neither is validated by the form request; the carrier adapters and Shipping::getType() read them directly.

ship_data.typeship_data.returnMeaningStored shippings.typeAppended shipping_type label
11 or absentRegular deliveryregularRegular delivery
12Double delivery — deliver and bring something back on the same visitdoubleDouble delivery
21 or absentCollection — pick up from the recipientcollectingCollecting delivery
22Collection requested with a return legdoubleCollecting delivery

The two representations disagree in the last row

shippings.type is computed by getType(), where return == 2 wins over everything; the shipping_type label is computed separately, where type == 2 wins. So type: 2 with return: 2 is stored as double while it is labelled Collecting delivery. Send return: 1 (or omit it) for a plain collection to stay out of that corner.

A type: 2 collection needs a collection address to exist

A collection is picked up from the recipient and delivered to the merchant, so the carrier needs the merchant's collection address. That address comes from the license (collect_street, collect_street_number, collect_city, collect_company), and from the request's shipment_settings only when the license carries none.

With both empty, a ship_data.type = 2 request was refused by the carrier with HTTP 500 and The params.to address.city field is required. — wording that points at the destination address and gives no hint that the missing piece is the collection address. So if a collection fails with a city or address validation message while the recipient address you sent is complete, check the license's collection settings first, or send shipment_settings.


POST /shipping/open_new_order ​

Create a shipment with the carrier. Persists the order, its recipient snapshot and its line items, calls the carrier, and returns the tracking code. Auth: license_key + domain. License: required.

This is the only v1 endpoint with duplicate protection — read Duplicate protection before you build retries on top of it.

Parameters ​

Body ​

FieldTypeRequiredDescription
license_keystringYesSee Authentication. Also forms part of the idempotency key.
domainstringYesSee Authentication.
tokenstringNoSanctum token. When sent, resolves order.wp_site_id from the WooCommerce site registered for domain.
ship_dataobjectYesCarrier-facing payload. See below.
orderobjectYesOrder envelope. See below.
order_itemsarrayNoLine items. Top level, not nested under order — this is the shape v2 changed.
order_items[].namestringYesRequired for every element when order_items is present.
order_items[].quantityintegerYesRequired for every element. Min 1.
order_items[].totalnumericYesRequired for every element.
shipment_settingsobjectNoCollection-address overrides: collect_street, collect_street_number, collect_city, collect_company. Used only when the license itself carries no collection settings — the license always wins. A collection (ship_data.type = 2) needs one of the two to be populated — see the warning in Shipment types.
codestringNoPassed through to the carrier adapter as its code setting.

Those six rules (ship_data, order, order_items and its three per-item rules) are the entire declared rule set. Everything inside ship_data and order is unvalidated by type, with one exception: a cross-field check requires a street, a city and a contact name to be resolvable from ship_data or from order.billing, and rejects the request otherwise. "Blank" there means null or whitespace-only — the literal string "0" is accepted as a street, deliberately (customers do type it).

ship_data ​

Forwarded to the carrier adapter and stored verbatim in shippings.shipment_data, so which keys have an effect depends on the carrier. These are the ones the v1 controller itself reads, defaults, or rewrites:

FieldTypeRequiredDescription
contact_namestringConditionalRecipient name. Falls back to order.billing.first_name + order.billing.last_name. Rejected only if all three are blank.
contact_phonestringNoFalls back to order.billing.phone. The presence check is commented out, so a shipment with no phone at all is accepted by us and left to the carrier.
contact_mailstringNoFalls back to order.billing.email.
streetstringConditionalFalls back to order.billing.address_1. Required (after that fallback).
numberstringNoHouse number. When a street was supplied it falls back to order.billing.address_2.
citystringConditionalFalls back to order.billing.city. Required (after that fallback).
companystringNoFalls back to order.billing.company.
typeintegerYesService direction. See Shipment types. Not validated, but the carriers and our own type resolution both require it.
returnintegerYesReturn leg. See Shipment types.
packagesintegerNoPackage count.
pickupstringNoPickup-point / distribution-point id, for pickup-point deliveries.
pickup_addressstringNoHuman-readable pickup-point address.
floorstringNoFloor.
apartmentstringNoApartment.
entrancestringNoEntrance.
house_nostringNoHouse number, for the carriers that read this key instead of number.
notestringNoDelivery note. Server-modified — see the note below.
extra_notestringNoAdditional note, for the carriers that support one.
exaction_datedate (Y-m-d)NoRequested pickup date. Defaults to today when blank or absent.
woo_idstringNoYour order id as shown to the carrier. Gets the license's order_prefix prepended when one is configured.
urgentbooleanNoUrgent handling.
motorintegerNoMotorcycle courier.
collectbooleanNoCollection flag, where the carrier distinguishes it from type.
is_cod / cod_amountmixedNoCash-on-delivery, for the carriers that support it.
IsManualbooleanNoMarks the shipment as manually entered.

ship_data.note is not sent as you send it

Before the carrier call, the note may be appended to with a gift-note marker (when order.meta_data contains a letter entry with a value) and with a compact product summary (when the license enables print_products_on_label_notes). If the license has send_note_on_order off, the note is set to null and nothing you sent is forwarded. ship_data.shipping_id and ship_data.license_id are also overwritten server-side.

order ​

FieldTypeRequiredDescription
order.idstringYesYour order identifier. Stored as orders.order_id, matched by POST /shipping/order, and part of the idempotency cache key — see the warning in Duplicate protection.
order.numberstringNoHuman order number. Defaults to order.id. Gets the license's order_prefix prepended when one is configured.
order.statusstringNoYour order status. Checked against the license's blocked-status list; a blocked status is refused with 400.
order.sourcestringNoPlatform label, default wordpress. Part of the order's identity for upsert. Sending it also skips the WooCommerce order-status push after a successful creation.
order.shippingobjectNoRecipient snapshot. When absent, order.billing is used instead.
order.billingobjectNoBilling block. Also the fallback source for ship_data.street, number, city, company, contact_name, contact_phone and contact_mail.
order.order_keystringNoPlatform order key. Part of the order's identity for upsert.
order.shopify_idmixedNoShopify store id. Part of the order's identity for upsert.
order.wix_idmixedNoWix site id. Part of the order's identity for upsert.
order.wp_site_idmixedNoConnected WooCommerce site id. Part of the order's identity for upsert; overwritten when token resolves a site for domain.
order.currencystringNoISO currency.
order.totalnumericNoOrder total.
order.total_taxnumericNoTax total.
order.discount_totalnumericNoDiscount total.
order.discount_taxnumericNoDiscount tax.
order.shipping_totalnumericNoShipping total.
order.shipping_taxnumericNoShipping tax.
order.shipping_linesarrayNoPlatform shipping lines.
order.cart_taxnumericNoCart tax.
order.prices_include_taxbooleanNoWhether prices include tax.
order.payment_methodstringNoPayment method.
order.meta_dataarrayNo{ key, value } pairs. A letter key with a non-null value appends a gift-note marker to the carrier note.

Any key you send inside order that is not in the list above is kept as order metadata rather than discarded.

order.shipping and order.billing both take first_name, last_name, company, address_1, address_2, city, state, postcode, country and phone; order.billing.email is additionally read as the contact e-mail fallback. Any other key inside order.shipping is stored as shipping metadata. The street number is parsed out of address_1 (or address_2) and stored separately.

order_items[] also accepts id, variation_id, variation_name, sku, price, image, is_virtual, gallery_images and meta_data beyond the three validated fields.

Duplicate protection ​

Creation is guarded by a cache-replay plus a lock, both keyed on the license and the order:

Value
Replay cache keyshipment_response_{license_key}_{order.id}
Cache TTL1 hour
Lock namewp_create_shipping_{license_key}_{order.id}
Lock TTL120 s
Block (wait) window120 s

What a caller observes:

  1. A retry inside the hour replays the first response verbatim — same body, same HTTP status — with data.idempotent: true added. No carrier call is made, no second shipment is created, and none of the post-creation side effects (delivered SMS, platform order updates, ShippingCreated listeners) fire again. That flag is your signal to skip your own side effects too.
  2. Only successful creations are cached. A failed attempt leaves nothing behind, so it stays retryable.
  3. A concurrent retry waits rather than failing. It parks on the lock for up to 120 s, and when the original commits it takes the replay path. Two clicks a second apart produce one shipment and two identical responses.
  4. If the wait window expires, you get 409 with Another shipment request for this order is in progress. Please try again. — the request is not attempted. Retry after a pause.
  5. After the hour, the same order can be shipped again. There is deliberately no database-level duplicate check: merchants legitimately create a second shipment for one order days or weeks later. Idempotency in v1 is a one-hour window, not a permanent one.

The key does not include the shipment type

shipment_response_{license_key}_{order.id} carries no service type. Create a regular delivery for order 1042 and then, within the hour, ask for a collection for order 1042: the second call replays the delivery response and no collection is created. This is a known sharp edge. If you need two service types for one order, either wait out the TTL or vary the order.id you send.

An absent order.id collapses the key

order.id is read as order.id ?? '', so a request without it uses the key shipment_response_{license_key}_. Every id-less request for that license shares that one key and will replay each other's responses. Always send order.id.

Example request ​

bash
curl --location 'https://app.shipos.co.il/api/shipping/open_new_order' \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data '{
  "license_key": "{license_key}",
  "domain": "your-store.example.com",
  "ship_data": {
    "contact_name": "Dana Levi",
    "contact_phone": "0521234567",
    "contact_mail": "dana@example.com",
    "street": "Herzl",
    "number": "10",
    "city": "Tel Aviv",
    "floor": "3",
    "apartment": "12",
    "type": 1,
    "return": 1,
    "packages": 1,
    "note": "Leave with doorman",
    "woo_id": "1042"
  },
  "order": {
    "id": "1042",
    "number": "1042",
    "status": "processing",
    "source": "custom",
    "currency": "ILS",
    "total": 199.90,
    "billing": {
      "first_name": "Dana",
      "last_name": "Levi",
      "phone": "0521234567",
      "email": "dana@example.com",
      "address_1": "Herzl 10",
      "city": "Tel Aviv",
      "postcode": "6100000",
      "country": "IL"
    },
    "shipping": {
      "first_name": "Dana",
      "last_name": "Levi",
      "phone": "0521234567",
      "address_1": "Herzl 10",
      "city": "Tel Aviv",
      "postcode": "6100000",
      "country": "IL"
    }
  },
  "order_items": [
    { "sku": "SKU-1", "name": "Blue mug", "quantity": 2, "total": 99.90 }
  ]
}'
js
// Node.js 18+ / browsers — no dependencies
const response = await fetch('https://app.shipos.co.il/api/shipping/open_new_order', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    license_key: process.env.SHIPOS_LICENSE_KEY,
    domain: 'your-store.example.com',
    ship_data: {
      contact_name: 'Dana Levi',
      contact_phone: '0521234567',
      contact_mail: 'dana@example.com',
      street: 'Herzl',
      number: '10',
      city: 'Tel Aviv',
      floor: '3',
      apartment: '12',
      type: 1,
      return: 1,
      packages: 1,
      note: 'Leave with doorman',
      woo_id: '1042',
    },
    order: {
      id: '1042',
      number: '1042',
      status: 'processing',
      source: 'custom',
      currency: 'ILS',
      total: 199.9,
      billing: {
        first_name: 'Dana',
        last_name: 'Levi',
        phone: '0521234567',
        email: 'dana@example.com',
        address_1: 'Herzl 10',
        city: 'Tel Aviv',
        postcode: '6100000',
        country: 'IL',
      },
      shipping: {
        first_name: 'Dana',
        last_name: 'Levi',
        phone: '0521234567',
        address_1: 'Herzl 10',
        city: 'Tel Aviv',
        postcode: '6100000',
        country: 'IL',
      },
    },
    order_items: [{ sku: 'SKU-1', name: 'Blue mug', quantity: 2, total: 99.9 }],
  }),
})

const payload = await response.json()

// v1 signals failure in the body; a carrier refusal is a 500 with the
// carrier's own message, and a replay is a 200 with data.idempotent.
if (!payload.success) {
  throw new Error(payload.message)
}

if (payload.data.idempotent) {
  console.log('replayed existing shipment', payload.data.code)
} else {
  console.log('created', payload.data.code)
}
php
<?php

use Illuminate\Support\Facades\Http;

$payload = Http::acceptJson()
    ->post('https://app.shipos.co.il/api/shipping/open_new_order', [
        'license_key' => config('services.shipos.license_key'),
        'domain' => 'your-store.example.com',
        'ship_data' => [
            'contact_name' => 'Dana Levi',
            'contact_phone' => '0521234567',
            'contact_mail' => 'dana@example.com',
            'street' => 'Herzl',
            'number' => '10',
            'city' => 'Tel Aviv',
            'floor' => '3',
            'apartment' => '12',
            'type' => 1,
            'return' => 1,
            'packages' => 1,
            'note' => 'Leave with doorman',
            'woo_id' => '1042',
        ],
        'order' => [
            'id' => '1042',
            'number' => '1042',
            'status' => 'processing',
            'source' => 'custom',
            'currency' => 'ILS',
            'total' => 199.90,
            'billing' => [
                'first_name' => 'Dana',
                'last_name' => 'Levi',
                'phone' => '0521234567',
                'email' => 'dana@example.com',
                'address_1' => 'Herzl 10',
                'city' => 'Tel Aviv',
                'postcode' => '6100000',
                'country' => 'IL',
            ],
            'shipping' => [
                'first_name' => 'Dana',
                'last_name' => 'Levi',
                'phone' => '0521234567',
                'address_1' => 'Herzl 10',
                'city' => 'Tel Aviv',
                'postcode' => '6100000',
                'country' => 'IL',
            ],
        ],
        'order_items' => [
            ['sku' => 'SKU-1', 'name' => 'Blue mug', 'quantity' => 2, 'total' => 99.90],
        ],
    ])
    ->json();

if (! $payload['success']) {
    throw new RuntimeException($payload['message']);
}

logger()->info('shipment '.$payload['data']['code'], [
    'replay' => $payload['data']['idempotent'] ?? false,
]);

Response 200 ​

json
{
  "data": {
    "status": 1,
    "code": "66747921",
    "response_status_code": 200,
    "shipping_id": 918342,
    "order_id": 771204,
    "shipping_uuid": "9b2c7f1e-3a4d-4c88-9f21-2f0d5a6b7c88",
    "effective_label_method": "shipos",
    "effective_label_type": "default",
    "shipping_line": "12",
    "shipping_line_desc": "Tel Aviv south",
    "line_branch": null,
    "tracking_url": "https://track.example-carrier.co.il/66747921",
    "price": null,
    "company": {
      "name": "HFD",
      "logo": "https://app.shipos.co.il/storage/companies/hfd.png"
    }
  },
  "message": "Shipping successfully created, shipping number: 66747921",
  "success": true,
  "warning": ""
}
FieldTypeDescription
data.statusintegerThe carrier adapter's status. 1 means created; it is what success is derived from.
data.codestringCarrier tracking code. Always a string — the normalizer casts it. Use this as the shipping_id parameter of the other endpoints.
data.response_status_codemixed | nullThe carrier's own status field, when it returned one.
data.shipping_idintegerOur internal shippings.id. Accepted by POST /shipping/{shipment}/status, not by the endpoints that take a shipping_id parameter.
data.order_idintegerOur internal orders.id.
data.shipping_uuidstringThe shipment's uuid, used by the ShipOS label URL.
data.effective_label_methodstringWhere the label comes from: shipos (we render it) or shipping_company (the carrier renders it).
data.effective_label_typestringLabel paper: default (thermal) or A4.
data.shipping_linestring | nullCarrier line / route, where the carrier reports one.
data.shipping_line_descstring | nullLine description; null for carriers that do not report it.
data.line_branchstring | nullBranch for the line; null for carriers that do not report it.
data.tracking_urlstring | nullThe carrier's sender-facing tracking URL, when returned.
data.pricemixed | nullCarrier-quoted price, when returned.
data.companyobject{ name, logo } of the carrier behind the license.
data.idempotentbooleanPresent only on a replay, always true. Absent on a fresh creation.

The envelope's message is not empty on success. It follows the pattern Shipping successfully created, shipping number: {code} — observed live as Shipping successfully created, shipping number: 68834253. It is a translated string, so read the code from data.code and never parse it out of the message.

A replayed response is the body above with "idempotent": true added inside data and the original HTTP status — including that original, non-empty message:

json
{
  "data": {
    "status": 1,
    "code": "66747921",
    "shipping_id": 918342,
    "idempotent": true
  },
  "message": "Shipping successfully created, shipping number: 66747921",
  "success": true,
  "warning": ""
}

A carrier refusal returns the same field set with success: false, HTTP 500, and the carrier's message:

json
{
  "data": {
    "status": 0,
    "code": "0",
    "response_status_code": 200,
    "shipping_id": 918343,
    "order_id": 771204,
    "shipping_uuid": "1c4d8e2a-5b6f-4a71-b0c3-9d8e7f6a5b41",
    "effective_label_method": "",
    "effective_label_type": "",
    "shipping_line": "",
    "company": { "name": "HFD", "logo": "https://app.shipos.co.il/storage/companies/hfd.png" }
  },
  "message": "The params.to address.city field is required.",
  "success": false,
  "warning": ""
}

Four details of that shape catch clients out. data.code is the string "0", not "" — a non-empty code is therefore not a success signal. data.response_status_code is 200, because the HTTP call to the carrier itself succeeded and the refusal is inside the carrier's own body; it does not mirror the 500 you receive. And effective_label_method, effective_label_type and shipping_line are all present as empty strings rather than absent, so a key-presence check will not tell a refusal from a creation. Branch on success and on data.status.

TIP

The shipment row survives a carrier refusal only in the response — the transaction is rolled back, so data.shipping_id and data.order_id point at ids that no longer exist. Treat a success: false create as "nothing happened", and retry.

Errors ​

StatusmessageWhen
400You have reached the utilization of the packageThe customer's monthly shipment quota for their package is exhausted.
400Shipment creation is blocked for orders with status: :statusThe license enables the order-status filter and order.status is on its blocked list.
403Invalid License KeyNo license with that license_key. (Returned as 403, not 404.)
403License key has been deactivatedThe license is inactive.
403Premium companies are not supported in this version of pluginThe carrier's company is premium and no token identified a user.
403You must be a premium member to use this companyPremium company, user without an active premium subscription.
409Another shipment request for this order is in progress. Please try again.Lock wait window expired — a concurrent create for the same license + order is still running. Nothing was created.
422first validation message, with all of them under data.errorsship_data / order missing, an order_items element incomplete, or no resolvable street, city or contact name. Uses the v1 envelope.
422Laravel default bodylicense_key or domain missing. Uses { "message": ..., "errors": {...} }, not the v1 envelope.
500carrier message, verbatimThe carrier rejected the shipment. success: false.
500An unexpected error occurred while creating the shipment. Please try again or contact support.Unhandled exception inside the creation path.

POST /shipping/get_order_details ​

Read a shipment's current carrier-side detail. Queries the carrier live on every call and returns the adapter's raw result — it does not update anything on our side. Auth: license_key + domain. License: required.

Parameters ​

Body ​

FieldTypeRequiredDescription
license_keystringYesSee Authentication.
domainstringYesSee Authentication.
shipping_idstringYesThe carrier tracking code (data.code from open_new_order). Matched against shippings.shipping_code, restricted to orders belonging to this license.

Example request ​

bash
curl --location 'https://app.shipos.co.il/api/shipping/get_order_details' \
--header 'Accept: application/json' \
--header 'Content-Type: 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/get_order_details', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    license_key: process.env.SHIPOS_LICENSE_KEY,
    domain: 'your-store.example.com',
    shipping_id: '66747921',
  }),
})

const payload = await response.json()

// The envelope is always success:true here — the carrier outcome is inside data.
if (payload.data?.success !== true) {
  throw new Error(payload.data?.message ?? 'carrier lookup failed')
}

console.log(payload.data.data.code, payload.data.data.text)
php
<?php

use Illuminate\Support\Facades\Http;

$payload = Http::acceptJson()
    ->post('https://app.shipos.co.il/api/shipping/get_order_details', [
        'license_key' => config('services.shipos.license_key'),
        'domain' => 'your-store.example.com',
        'shipping_id' => '66747921',
    ])
    ->json();

abort_unless(($payload['data']['success'] ?? false) === true, 502, 'carrier lookup failed');

logger()->info('status', $payload['data']['data']);

Response 200 ​

data is the carrier adapter's result object, unwrapped — which is why the status lives at data.data. The keys inside depend on the carrier; code and text are the two every adapter populates.

json
{
  "data": {
    "data": {
      "code": 2,
      "text": "Delivered",
      "receiver": "Dana Levi"
    },
    "error": false,
    "success": true
  },
  "message": "",
  "success": true,
  "warning": ""
}

code is not string-normalised on this endpoint

normalize_shipment_response casts only a top-level data.code, and here the carrier code sits one level deeper, at data.data.code. It therefore arrives exactly as the carrier sent it and can be an integer — observed live as the integer 2, not the string it is elsewhere. This is the one place on this page where the "code is always a string" guarantee made for open_new_order and get_shipping_status does not hold. Read data.data.code loosely: cast it before comparing, or accept both types.

A failed carrier lookup is still HTTP 200 with success: true

When the carrier call fails, the adapter returns { "error": true, "message": ..., "success": false } and that object becomes data unchanged — the envelope around it still says success: true and the status is still 200. Check data.success / data.error, not the envelope:

json
{
  "data": {
    "error": true,
    "message": "Error retrieving tracking info",
    "success": false
  },
  "message": "",
  "success": true,
  "warning": ""
}

Errors ​

StatusmessageWhen
403license messagesSee Authentication.
404Laravel default bodyNo shipment with that tracking code under this license. Body is { "message": "No query results for model [App\\Models\\Shipping]." }, not the v1 envelope.
422Laravel default bodyshipping_id, license_key or domain missing.

POST /shipping/get_shipping_status ​

Refresh a shipment's status from the carrier and return it. Unlike get_order_details, this writes: it stores the new status on the shipment (and, for some carriers, deactivates a cancelled shipment) before answering. Auth: license_key + domain. License: required.

Parameters ​

Body ​

Identical to get_order_details — the same form request backs both.

FieldTypeRequiredDescription
license_keystringYesSee Authentication.
domainstringYesSee Authentication.
shipping_idstringYesThe carrier tracking code. Matched against shippings.shipping_code, restricted to orders belonging to this license.

Example request ​

bash
curl --location 'https://app.shipos.co.il/api/shipping/get_shipping_status' \
--header 'Accept: application/json' \
--header 'Content-Type: 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/get_shipping_status', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    license_key: process.env.SHIPOS_LICENSE_KEY,
    domain: 'your-store.example.com',
    shipping_id: '66747921',
  }),
})

const { data } = await response.json()

// data is null when the carrier lookup failed — not "no status yet".
if (data === null) {
  throw new Error('status unavailable')
}

console.log(data.code, data.text, data.is_delivered)
php
<?php

use Illuminate\Support\Facades\Http;

$status = Http::acceptJson()
    ->post('https://app.shipos.co.il/api/shipping/get_shipping_status', [
        'license_key' => config('services.shipos.license_key'),
        'domain' => 'your-store.example.com',
        'shipping_id' => '66747921',
    ])
    ->json('data');

abort_if($status === null, 502, 'status unavailable');

logger()->info('delivered: '.var_export($status['is_delivered'], true));

Response 200 ​

json
{
  "data": {
    "code": "99",
    "text": "Delivered",
    "receiver": "Dana Levi",
    "is_delivered": true
  },
  "message": "",
  "success": true,
  "warning": ""
}
FieldTypeDescription
data.codestringThe carrier's status code. Always a string — the normalizer casts it. Codes are carrier-specific.
data.textstring | nullThe carrier's own status description.
data.is_deliveredbooleanAdded by us: true when code is in the carrier's delivered-status list. Forced to false when the carrier reports isCancelled: "y".

Any other keys are whatever the carrier adapter stored (receiver, isCancelled, and so on) and vary by carrier. data.is_delivered is only added when the stored status has a code.

data: null means the lookup failed

Several carrier adapters signal failure by returning an error array instead of the shipment. This handler does not branch on that, so the response is HTTP 200, success: true, and data: null. A null payload is a failed carrier call, not "status not known yet" — retry rather than treating the shipment as statusless.

Errors ​

StatusmessageWhen
403license messagesSee Authentication.
404Laravel default bodyNo shipment with that tracking code under this license.
422Laravel default bodyshipping_id, license_key or domain missing.

POST /shipping/change_order_status ​

Cancel a shipment with the carrier. On success the shipment is also marked inactive on our side (is_active = false). Auth: license_key + domain. License: required.

Parameters ​

Body ​

FieldTypeRequiredDescription
license_keystringYesSee Authentication.
domainstringYesSee Authentication.
shipping_idstringYesThe carrier tracking code. Matched against shippings.shipping_code. The lookup accepts the shipment either through its order's license or through the shipment's own license_id, so shipments created outside an order context are cancellable too.

Example request ​

bash
curl --location 'https://app.shipos.co.il/api/shipping/change_order_status' \
--header 'Accept: application/json' \
--header 'Content-Type: 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/change_order_status', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    license_key: process.env.SHIPOS_LICENSE_KEY,
    domain: 'your-store.example.com',
    shipping_id: '66747921',
  }),
})

// A refused cancellation is also HTTP 200 — success is the only signal.
const payload = await response.json()

if (!payload.success) {
  throw new Error(payload.message)
}

console.log(payload.message) // carrier's confirmation text
php
<?php

use Illuminate\Support\Facades\Http;

$payload = Http::acceptJson()
    ->post('https://app.shipos.co.il/api/shipping/change_order_status', [
        'license_key' => config('services.shipos.license_key'),
        'domain' => 'your-store.example.com',
        'shipping_id' => '66747921',
    ])
    ->json();

// HTTP 200 covers both outcomes; ->throw() would not catch a refusal.
if (! $payload['success']) {
    throw new RuntimeException($payload['message']);
}

logger()->info($payload['message']);

Response 200 ​

Cancelled:

json
{
  "data": [],
  "message": "Successfully cancelled shipment",
  "success": true,
  "warning": ""
}

Refused by the carrier — also HTTP 200:

json
{
  "data": {
    "error": "Shipment already collected"
  },
  "message": "Cancellation not permitted at this stage",
  "success": false,
  "warning": ""
}

Not found under this license — again HTTP 200:

json
{
  "data": [],
  "message": "אירעה שגיאה",
  "success": false,
  "warning": ""
}

That message is the Something went wrong translation key; the live server rendered it as אירעה שגיאה, which is what a caller actually receives.

This endpoint never returns a failure status code

All three bodies above are HTTP 200; success is the only reliable signal, and data.error carries the carrier's reason when there is one. Note also that the not-found case is reported with the generic Something went wrong rather than a 404, so a wrong tracking code and a carrier refusal are distinguishable only by message and data — and message is localised, so it is not something you can match on.

Cancelling twice is indistinguishable from cancelling once

An already-cancelled shipment is accepted again: HTTP 200, success: true, and the same Successfully cancelled shipment message. There is no "already cancelled" signal anywhere in the body — no distinct message, no flag, and data stays []. A caller therefore cannot tell a real cancellation from a no-op, and a retry after a timeout looks successful whether or not the first attempt got through. If you need the current state, read is_active from POST /shipping/order rather than inferring it from this response.

Errors ​

StatusmessageWhen
200Something went wrong (rendered אירעה שגיאה)No shipment with that tracking code under this license. success: false.
200carrier messageThe carrier refused the cancellation. success: false, reason in data.error.
403license messagesSee Authentication.
422Laravel default bodyshipping_id, license_key or domain missing.

POST /shipping/order ​

List the shipments already created for a set of your order ids. Read-only; no carrier call is made for the shipment rows themselves. Auth: license_key + domain. License: required.

Parameters ​

Body ​

FieldTypeRequiredDescription
license_keystringYesSee Authentication. Without a token, this is what scopes the lookup: every license sharing this key.
domainstringYesSee Authentication.
idsarrayYesYour order identifiers, matched against orders.order_id. Empty-ish elements are dropped; if nothing survives, the call fails.
tokenstringNoSanctum token. When sent, the lookup is scoped to every license the token's user owns instead of just license_key.

Example request ​

bash
curl --location 'https://app.shipos.co.il/api/shipping/order' \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data '{
  "license_key": "{license_key}",
  "domain": "your-store.example.com",
  "ids": ["1042", "1043"]
}'
js
// Node.js 18+ / browsers — no dependencies
const response = await fetch('https://app.shipos.co.il/api/shipping/order', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    license_key: process.env.SHIPOS_LICENSE_KEY,
    domain: 'your-store.example.com',
    ids: ['1042', '1043'],
  }),
})

// This endpoint answers with a bare array — and reports failures as HTTP 500.
if (!response.ok) {
  const { message } = await response.json()
  throw new Error(message)
}

const shipments = await response.json()
console.log(`${shipments.length} shipments`)
php
<?php

use Illuminate\Support\Facades\Http;

$shipments = Http::acceptJson()
    ->post('https://app.shipos.co.il/api/shipping/order', [
        'license_key' => config('services.shipos.license_key'),
        'domain' => 'your-store.example.com',
        'ids' => ['1042', '1043'],
    ])
    ->throw()
    ->json();

logger()->info(count($shipments).' shipments');

Response 200 ​

No envelope here

This is the one endpoint on this page that does not use apiResponse(). It returns a bare JSON array of shipment rows, flattened across the matched orders — no data, message, success or warning. An empty result is [], including when none of the ids matched anything.

json
[
  {
    "shipping_id": 918342,
    "delivery_number": "66747921",
    "is_active": true,
    "created_at": "2026-07-29T09:14:00.000000Z",
    "updated_at": "2026-07-29T09:14:00.000000Z",
    "delivery_time": null,
    "type": "1",
    "return": "1",
    "company": {
      "id": 3,
      "name": "HFD",
      "email": "ops@example-carrier.example",
      "description": null,
      "phone": "036000000",
      "license_quota": 50,
      "service_url": "https://api.example-carrier.example/v1/",
      "service_code": null,
      "service_username": null,
      "service_password": null,
      "provider_id": 4,
      "logo": "companies/hfd.png",
      "is_premium": false,
      "display_company_logo_on_label": false,
      "created_at": "2023-02-16T07:14:18.000000Z",
      "updated_at": "2026-07-29T09:14:00.000000Z",
      "deleted_at": null,
      "available_licenses_count": -49,
      "provider": {
        "id": 4,
        "name": "hfd",
        "endpoints": { "create": "…", "status": "…", "label": "…" },
        "created_at": "2023-02-16T07:14:18.000000Z",
        "updated_at": "2023-02-16T07:14:18.000000Z"
      },
      "provider_settings": [
        {
          "id": 87,
          "provider_id": 4,
          "company_id": 3,
          "settings": { "…": "…" },
          "created_at": "2023-02-20T09:29:32.000000Z",
          "updated_at": "2026-07-01T06:11:40.000000Z"
        }
      ],
      "current_provider_settings": {
        "id": 87,
        "provider_id": 4,
        "company_id": 3,
        "settings": { "…": "…" }
      }
    },
    "license_key": "{license_key}",
    "code": "66747921",
    "shipping_line": "12",
    "line_branch": ""
  }
]
FieldTypeDescription
shipping_idintegerOur internal shippings.id.
delivery_numberstring | nullThe carrier tracking code (shippings.shipping_code).
is_activebooleanfalse once the shipment has been cancelled.
created_atdatetimeWhen the shipment row was created.
updated_atdatetimeLast update.
delivery_timestring | nullRead out of the stored shipment payload; null for shipments created without one.
typestring | nullship_data.type as it was stored. See Shipment types.
returnstring | nullship_data.return as it was stored.
companyobject | nullThe entire companies row behind the shipment's license, plus three eager-loaded relations — provider, provider_settings[] and current_provider_settings. Not a curated shape; see the warning below.
license_keystringThe license the shipment belongs to — the discriminator when a token widened the lookup across several licenses.
codestringThe code field of the stored carrier response; "" when the carrier returned none.
shipping_linestringCarrier line / route, re-derived from the stored carrier response.
line_branchstring | nullBranch for that line; null or "" for carriers that do not report one.

company carries internal carrier and quota data

Company declares $guarded = [] and no $hidden, so the row is serialised whole and every column on companies shows up here — including service_url (the carrier's API base URL), the service_code / service_username / service_password columns, license_quota, is_premium, display_company_logo_on_label and deleted_at. The appended available_licenses_count is a live counter of quota headroom and can be negative (observed: -49) when a company is over its license_quota. provider.endpoints likewise carries the carrier's endpoint map, and provider_settings[] is one row per configured provider.

None of this is meant for a storefront. Pick the two or three fields you actually need out of company before you pass this array anywhere a customer can see it, and do not log the response whole.

Errors ​

Failures are HTTP 500

This handler reports every failure through the failResponse() helper, whose default status is 500. A bad request and a server fault are indistinguishable by status code; use message. The body is { "errors": ..., "message": ..., "success": false } — a third shape, different from both the v1 envelope and the success array.

StatusmessageWhen
403license messagesSee Authentication.
500Validation Failedlicense_key, domain or ids missing or of the wrong type. Details under errors.
500Invalid license keyNo license id resolved from the token or the key.
500Order Id not foundids was present but every element was empty.

POST /shipping/{shipment}/status ​

Refresh one shipment's carrier status by its internal id. This is the endpoint the ShipOS admin's own "refresh status" control calls. Auth: none. License: resolved from the shipment.

Unauthenticated, and the response discloses credentials

This route is registered on its own, before the shipping prefix group, so it runs none of that group's middleware: no hasLicense, no request logging, no normalize_shipment_response. It requires no authentication whatsoever. Verified with zero credentials — no license_key, no domain, no token, no auth header of any kind — and the call still answers HTTP 200. The route binds the sequential integer shippings.id, so ids are trivially enumerable.

The response data eager-loads license, order and provider, and therefore discloses:

  • data.license.key — the licence key, in plaintext (32 characters). data.order.license.key is the same key a second time.
  • data.order.customer — the entire owning user row, 47 keys. User::$hidden covers only password, remember_token and the two-factor columns, so the credential fields are not hidden: the owner's email, their v2 API credentials (client_id, client_secret) and their Google OAuth material (google_token, google_refresh_token, google_api_key) all appear in the JSON.

Anyone who can reach this route can walk the id space and harvest licence keys and API credentials. Treat the whole response body as sensitive: never proxy it to a browser, never log it client-side, never paste it into an error report or an issue tracker, and reduce it to the handful of fields you need the moment it arrives.

The refresh also has side effects — it writes the new status to the shipment, can send the recipient the "shipment arrived" SMS, and can write status tags onto the merchant's Shopify order — so an enumerating caller is not read-only either.

Treat this endpoint as internal. For integrations use get_shipping_status instead: it performs the same refresh under license authentication and returns only the status.

Parameters ​

Path ​

FieldTypeRequiredDescription
shipmentintegerYesThe internal shippings.id — the data.shipping_id from open_new_order. Not the uuid and not the tracking code. An unknown id is a 404.

The response format is chosen by content negotiation, not by a parameter: send Accept: application/json explicitly. Merely not contradicting it is not enough — Accept: */*, which is curl's own default when you pass no Accept header, takes the browser branch too and answers 302. Any Accept that does not actually name application/json gets the redirect plus a flash message instead of JSON.

Example request ​

bash
curl --location --request POST 'https://app.shipos.co.il/api/shipping/918342/status' \
--header 'Accept: application/json'
js
// Node.js 18+ / browsers — no dependencies
// Accept: application/json is required, otherwise this answers with a redirect.
const response = await fetch('https://app.shipos.co.il/api/shipping/918342/status', {
  method: 'POST',
  headers: { Accept: 'application/json' },
})

const payload = await response.json()

if (!payload.success) {
  throw new Error(payload.message)
}

console.log(payload.data.status.code, payload.data.shipping_type)
php
<?php

use Illuminate\Support\Facades\Http;

$payload = Http::acceptJson()
    ->post('https://app.shipos.co.il/api/shipping/918342/status')
    ->json();

if (! ($payload['success'] ?? false)) {
    throw new RuntimeException($payload['message']);
}

logger()->info('status', $payload['data']['status']);

Response 200 ​

data is the whole shippings row as Eloquent serialises it — every column, plus the model's appended attributes and its status log. It is not a curated contract; columns added to the table appear here.

The example below is abridged. The real body also carries the eager-loaded license, order (with its own nested license and customer) and provider objects, which is where the credential disclosure described in the box above lives; they are left out here deliberately.

json
{
  "data": {
    "id": 918342,
    "uuid": "9b2c7f1e-3a4d-4c88-9f21-2f0d5a6b7c88",
    "shipping_code": "66747921",
    "type": "regular",
    "is_active": true,
    "first_name": "Dana",
    "last_name": "Levi",
    "city": "Tel Aviv",
    "address_1": "Herzl",
    "street_number": "10",
    "phone": "0521234567",
    "status": {
      "code": "99",
      "text": "Delivered"
    },
    "shipment_data": { "type": 1, "return": 1, "packages": 1 },
    "response": {
      "response": { "code": "66747921", "status": 1 },
      "error": "",
      "status": 200
    },
    "created_at": "2026-07-29T09:14:00.000000Z",
    "updated_at": "2026-07-31T11:02:14.000000Z",
    "shipping_type": "Regular delivery",
    "short_code": "bs6oM=",
    "short_tracking_code": "e6Pns4k",
    "effective_label_method": "shipos",
    "effective_label_type": "default",
    "shipping_log": [
      {
        "id": 55120,
        "shipping_id": 918342,
        "status_code": "99",
        "status": "Delivered",
        "created_at": "2026-07-31T11:02:14.000000Z"
      }
    ]
  },
  "message": "Shipping status successfully updated",
  "success": true,
  "warning": ""
}
FieldTypeDescription
data.statusobject | nullThe freshly stored carrier status — code and text, plus whatever else that carrier reports. Not normalized here: code can be an integer, because this route does not run normalize_shipment_response.
data.typestringregular, double or collecting. See Shipment types.
data.shipping_typestringAppended, translated label for the same thing.
data.short_codestringAppended, and not a short form of the tracking code — it is not derived from it at all. Shipping::getShortCodeAttribute() returns an opaque ~6-character encoding of the shipment's row id (shippings.id); for tracking code 68834253 it was observed as bs6oM=. Treat it as an opaque token: it can contain = and other non-alphanumeric characters.
data.short_tracking_codestring | nullA separate stored shippings column, and a different value again — neither short_code nor any substring of the tracking code (observed: e6Pns4k). Also opaque.
data.effective_label_methodstringshipos or shipping_company.
data.effective_label_typestringdefault (thermal) or A4.
data.shipment_dataobjectThe stored ship_data merged with the resolved carrier settings.
data.responseobjectA wrapper, not the carrier payload itself: { "response": {…carrier payload…}, "error": "", "status": 200 }. The carrier's own body is one level down at data.response.response; data.response.status is the carrier's HTTP status as we captured it, and data.response.error is "" when there was none.
data.shipping_logarrayThe shipment's status-log rows, newest first. Each row carries status_code and status alongside the ids and timestamps. A row is appended only when the status changes — see the note below.

shipping_log records changes, not calls

It is not a call log. Five consecutive status refreshes of the same shipment produced exactly one log row: a row is appended when the carrier reports a status different from the stored one, and a refresh that returns the same status adds nothing. So the number of rows tells you how many times the status moved, not how often anyone asked, and the newest row's created_at is the time of the last change, not of your call.

Only twelve carriers are wired into this handler: Baldar, Tapuz, Run Software, HFD, Done, Cargo, LionWheel, GetPackage, Orian, Zig Zag, Ship UPS and Cactus. A shipment on any other provider is refused with Not yet supported for: <provider>.

Errors ​

Failures here use the failResponse() helper — body { "errors": [], "message": ..., "success": false }, status 500.

StatusmessageWhen
404Laravel default bodyNo shipment with that id.
500No order exists for this shipmentThe shipment has no order attached.
500No license exists for this shipmentNeither the order nor the shipment resolves a license.
500No valid license settings for <provider>The license has no settings row for the shipment's provider.
500Not yet supported for: <provider>The shipment's carrier is not one of the twelve wired in above.
500carrier errorThe carrier's status lookup failed.

Without an Accept header that names application/json none of these are returned as JSON: the handler answers 302 with the message in the session flash instead. That includes the */* curl sends by default.