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.
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | Yes | licenses.key — the carrier account the call acts under. |
domain | string | Yes | The 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. |
token | string | No | A 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:
{
"data": {},
"message": "",
"success": true,
"warning": ""
}| Field | Type | Description |
|---|---|---|
data | mixed | The payload. Shape differs per endpoint — object, array, model row, or null. |
message | string | Human-readable outcome. On carrier failures this is the carrier's own message, verbatim (often Hebrew), not a ShipOS string. |
success | boolean | Whether the operation succeeded. |
warning | string | Reserved; 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.type | ship_data.return | Meaning | Stored shippings.type | Appended shipping_type label |
|---|---|---|---|---|
1 | 1 or absent | Regular delivery | regular | Regular delivery |
1 | 2 | Double delivery — deliver and bring something back on the same visit | double | Double delivery |
2 | 1 or absent | Collection — pick up from the recipient | collecting | Collecting delivery |
2 | 2 | Collection requested with a return leg | double | Collecting 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
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | Yes | See Authentication. Also forms part of the idempotency key. |
domain | string | Yes | See Authentication. |
token | string | No | Sanctum token. When sent, resolves order.wp_site_id from the WooCommerce site registered for domain. |
ship_data | object | Yes | Carrier-facing payload. See below. |
order | object | Yes | Order envelope. See below. |
order_items | array | No | Line items. Top level, not nested under order — this is the shape v2 changed. |
order_items[].name | string | Yes | Required for every element when order_items is present. |
order_items[].quantity | integer | Yes | Required for every element. Min 1. |
order_items[].total | numeric | Yes | Required for every element. |
shipment_settings | object | No | Collection-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. |
code | string | No | Passed 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:
| Field | Type | Required | Description |
|---|---|---|---|
contact_name | string | Conditional | Recipient name. Falls back to order.billing.first_name + order.billing.last_name. Rejected only if all three are blank. |
contact_phone | string | No | Falls 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_mail | string | No | Falls back to order.billing.email. |
street | string | Conditional | Falls back to order.billing.address_1. Required (after that fallback). |
number | string | No | House number. When a street was supplied it falls back to order.billing.address_2. |
city | string | Conditional | Falls back to order.billing.city. Required (after that fallback). |
company | string | No | Falls back to order.billing.company. |
type | integer | Yes | Service direction. See Shipment types. Not validated, but the carriers and our own type resolution both require it. |
return | integer | Yes | Return leg. See Shipment types. |
packages | integer | No | Package count. |
pickup | string | No | Pickup-point / distribution-point id, for pickup-point deliveries. |
pickup_address | string | No | Human-readable pickup-point address. |
floor | string | No | Floor. |
apartment | string | No | Apartment. |
entrance | string | No | Entrance. |
house_no | string | No | House number, for the carriers that read this key instead of number. |
note | string | No | Delivery note. Server-modified — see the note below. |
extra_note | string | No | Additional note, for the carriers that support one. |
exaction_date | date (Y-m-d) | No | Requested pickup date. Defaults to today when blank or absent. |
woo_id | string | No | Your order id as shown to the carrier. Gets the license's order_prefix prepended when one is configured. |
urgent | boolean | No | Urgent handling. |
motor | integer | No | Motorcycle courier. |
collect | boolean | No | Collection flag, where the carrier distinguishes it from type. |
is_cod / cod_amount | mixed | No | Cash-on-delivery, for the carriers that support it. |
IsManual | boolean | No | Marks 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
| Field | Type | Required | Description |
|---|---|---|---|
order.id | string | Yes | Your 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.number | string | No | Human order number. Defaults to order.id. Gets the license's order_prefix prepended when one is configured. |
order.status | string | No | Your order status. Checked against the license's blocked-status list; a blocked status is refused with 400. |
order.source | string | No | Platform 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.shipping | object | No | Recipient snapshot. When absent, order.billing is used instead. |
order.billing | object | No | Billing block. Also the fallback source for ship_data.street, number, city, company, contact_name, contact_phone and contact_mail. |
order.order_key | string | No | Platform order key. Part of the order's identity for upsert. |
order.shopify_id | mixed | No | Shopify store id. Part of the order's identity for upsert. |
order.wix_id | mixed | No | Wix site id. Part of the order's identity for upsert. |
order.wp_site_id | mixed | No | Connected WooCommerce site id. Part of the order's identity for upsert; overwritten when token resolves a site for domain. |
order.currency | string | No | ISO currency. |
order.total | numeric | No | Order total. |
order.total_tax | numeric | No | Tax total. |
order.discount_total | numeric | No | Discount total. |
order.discount_tax | numeric | No | Discount tax. |
order.shipping_total | numeric | No | Shipping total. |
order.shipping_tax | numeric | No | Shipping tax. |
order.shipping_lines | array | No | Platform shipping lines. |
order.cart_tax | numeric | No | Cart tax. |
order.prices_include_tax | boolean | No | Whether prices include tax. |
order.payment_method | string | No | Payment method. |
order.meta_data | array | No | { 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 key | shipment_response_{license_key}_{order.id} |
| Cache TTL | 1 hour |
| Lock name | wp_create_shipping_{license_key}_{order.id} |
| Lock TTL | 120 s |
| Block (wait) window | 120 s |
What a caller observes:
- A retry inside the hour replays the first response verbatim — same body, same HTTP status — with
data.idempotent: trueadded. No carrier call is made, no second shipment is created, and none of the post-creation side effects (delivered SMS, platform order updates,ShippingCreatedlisteners) fire again. That flag is your signal to skip your own side effects too. - Only successful creations are cached. A failed attempt leaves nothing behind, so it stays retryable.
- 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.
- If the wait window expires, you get
409withAnother shipment request for this order is in progress. Please try again.— the request is not attempted. Retry after a pause. - 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
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 }
]
}'// 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
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
{
"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": ""
}| Field | Type | Description |
|---|---|---|
data.status | integer | The carrier adapter's status. 1 means created; it is what success is derived from. |
data.code | string | Carrier tracking code. Always a string — the normalizer casts it. Use this as the shipping_id parameter of the other endpoints. |
data.response_status_code | mixed | null | The carrier's own status field, when it returned one. |
data.shipping_id | integer | Our internal shippings.id. Accepted by POST /shipping/{shipment}/status, not by the endpoints that take a shipping_id parameter. |
data.order_id | integer | Our internal orders.id. |
data.shipping_uuid | string | The shipment's uuid, used by the ShipOS label URL. |
data.effective_label_method | string | Where the label comes from: shipos (we render it) or shipping_company (the carrier renders it). |
data.effective_label_type | string | Label paper: default (thermal) or A4. |
data.shipping_line | string | null | Carrier line / route, where the carrier reports one. |
data.shipping_line_desc | string | null | Line description; null for carriers that do not report it. |
data.line_branch | string | null | Branch for the line; null for carriers that do not report it. |
data.tracking_url | string | null | The carrier's sender-facing tracking URL, when returned. |
data.price | mixed | null | Carrier-quoted price, when returned. |
data.company | object | { name, logo } of the carrier behind the license. |
data.idempotent | boolean | Present 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:
{
"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:
{
"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
| Status | message | When |
|---|---|---|
| 400 | You have reached the utilization of the package | The customer's monthly shipment quota for their package is exhausted. |
| 400 | Shipment creation is blocked for orders with status: :status | The license enables the order-status filter and order.status is on its blocked list. |
| 403 | Invalid License Key | No license with that license_key. (Returned as 403, not 404.) |
| 403 | License key has been deactivated | The license is inactive. |
| 403 | Premium companies are not supported in this version of plugin | The carrier's company is premium and no token identified a user. |
| 403 | You must be a premium member to use this company | Premium company, user without an active premium subscription. |
| 409 | Another 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. |
| 422 | first validation message, with all of them under data.errors | ship_data / order missing, an order_items element incomplete, or no resolvable street, city or contact name. Uses the v1 envelope. |
| 422 | Laravel default body | license_key or domain missing. Uses { "message": ..., "errors": {...} }, not the v1 envelope. |
| 500 | carrier message, verbatim | The carrier rejected the shipment. success: false. |
| 500 | An 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
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | Yes | See Authentication. |
domain | string | Yes | See Authentication. |
shipping_id | string | Yes | The carrier tracking code (data.code from open_new_order). Matched against shippings.shipping_code, restricted to orders belonging to this license. |
Example request
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"
}'// 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
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.
{
"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:
{
"data": {
"error": true,
"message": "Error retrieving tracking info",
"success": false
},
"message": "",
"success": true,
"warning": ""
}Errors
| Status | message | When |
|---|---|---|
| 403 | license messages | See Authentication. |
| 404 | Laravel default body | No shipment with that tracking code under this license. Body is { "message": "No query results for model [App\\Models\\Shipping]." }, not the v1 envelope. |
| 422 | Laravel default body | shipping_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.
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | Yes | See Authentication. |
domain | string | Yes | See Authentication. |
shipping_id | string | Yes | The carrier tracking code. Matched against shippings.shipping_code, restricted to orders belonging to this license. |
Example request
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"
}'// 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
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
{
"data": {
"code": "99",
"text": "Delivered",
"receiver": "Dana Levi",
"is_delivered": true
},
"message": "",
"success": true,
"warning": ""
}| Field | Type | Description |
|---|---|---|
data.code | string | The carrier's status code. Always a string — the normalizer casts it. Codes are carrier-specific. |
data.text | string | null | The carrier's own status description. |
data.is_delivered | boolean | Added 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
| Status | message | When |
|---|---|---|
| 403 | license messages | See Authentication. |
| 404 | Laravel default body | No shipment with that tracking code under this license. |
| 422 | Laravel default body | shipping_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
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | Yes | See Authentication. |
domain | string | Yes | See Authentication. |
shipping_id | string | Yes | The 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
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"
}'// 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
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:
{
"data": [],
"message": "Successfully cancelled shipment",
"success": true,
"warning": ""
}Refused by the carrier — also HTTP 200:
{
"data": {
"error": "Shipment already collected"
},
"message": "Cancellation not permitted at this stage",
"success": false,
"warning": ""
}Not found under this license — again HTTP 200:
{
"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
| Status | message | When |
|---|---|---|
| 200 | Something went wrong (rendered אירעה שגיאה) | No shipment with that tracking code under this license. success: false. |
| 200 | carrier message | The carrier refused the cancellation. success: false, reason in data.error. |
| 403 | license messages | See Authentication. |
| 422 | Laravel default body | shipping_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
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | Yes | See Authentication. Without a token, this is what scopes the lookup: every license sharing this key. |
domain | string | Yes | See Authentication. |
ids | array | Yes | Your order identifiers, matched against orders.order_id. Empty-ish elements are dropped; if nothing survives, the call fails. |
token | string | No | Sanctum token. When sent, the lookup is scoped to every license the token's user owns instead of just license_key. |
Example request
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"]
}'// 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
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.
[
{
"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": ""
}
]| Field | Type | Description |
|---|---|---|
shipping_id | integer | Our internal shippings.id. |
delivery_number | string | null | The carrier tracking code (shippings.shipping_code). |
is_active | boolean | false once the shipment has been cancelled. |
created_at | datetime | When the shipment row was created. |
updated_at | datetime | Last update. |
delivery_time | string | null | Read out of the stored shipment payload; null for shipments created without one. |
type | string | null | ship_data.type as it was stored. See Shipment types. |
return | string | null | ship_data.return as it was stored. |
company | object | null | The 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_key | string | The license the shipment belongs to — the discriminator when a token widened the lookup across several licenses. |
code | string | The code field of the stored carrier response; "" when the carrier returned none. |
shipping_line | string | Carrier line / route, re-derived from the stored carrier response. |
line_branch | string | null | Branch 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.
| Status | message | When |
|---|---|---|
| 403 | license messages | See Authentication. |
| 500 | Validation Failed | license_key, domain or ids missing or of the wrong type. Details under errors. |
| 500 | Invalid license key | No license id resolved from the token or the key. |
| 500 | Order Id not found | ids 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.keyis the same key a second time.data.order.customer— the entire owning user row, 47 keys.User::$hiddencovers onlypassword,remember_tokenand the two-factor columns, so the credential fields are not hidden: the owner'semail, 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
| Field | Type | Required | Description |
|---|---|---|---|
shipment | integer | Yes | The 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
curl --location --request POST 'https://app.shipos.co.il/api/shipping/918342/status' \
--header 'Accept: application/json'// 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
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.
{
"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": ""
}| Field | Type | Description |
|---|---|---|
data.status | object | null | The 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.type | string | regular, double or collecting. See Shipment types. |
data.shipping_type | string | Appended, translated label for the same thing. |
data.short_code | string | Appended, 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_code | string | null | A 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_method | string | shipos or shipping_company. |
data.effective_label_type | string | default (thermal) or A4. |
data.shipment_data | object | The stored ship_data merged with the resolved carrier settings. |
data.response | object | A 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_log | array | The 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.
| Status | message | When |
|---|---|---|
| 404 | Laravel default body | No shipment with that id. |
| 500 | No order exists for this shipment | The shipment has no order attached. |
| 500 | No license exists for this shipment | Neither the order nor the shipment resolves a license. |
| 500 | No valid license settings for <provider> | The license has no settings row for the shipment's provider. |
| 500 | Not yet supported for: <provider> | The shipment's carrier is not one of the twelve wired in above. |
| 500 | carrier error | The 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.