Skip to content

App users ​

Three endpoints back the ShipOS recipient app — the consumer-facing app a person receiving a parcel uses to see where it is. They register a recipient by phone number, hand back a Sanctum token, and list the shipments addressed to that phone.

Not for merchant integrations

This group serves the first-party recipient app, not stores. It has no license_key, no domain and no notion of a carrier account: the actor is a recipient, identified by phone number, and the gate is a shared secret held by the app build. Most integrators will never call these. If you are shipping parcels, you want Shipments; if you are showing tracking to a buyer, you want the tracking surface, not this.

Authentication ​

Every route in the group is behind needs_app_secret, which is a single header comparison:

HeaderRequiredDescription
X-App-SecretyesMust be identical to the server's configured app secret (API_SECRET in the server environment). There is no per-client value and nothing is derived from the request — the middleware compares the header to that one string.

A missing or wrong header stops the request in the middleware:

json
{
  "errors": [],
  "message": "Invalid Secret Key",
  "success": false
}

An invalid secret returns HTTP 500

The rejection uses this application's generic failure helper, whose default status is 500. It is not a 401 and not a 403. Branch on success, not on the status class. On GET /shipping/phone_number you only ever see this response once you already hold a valid bearer token — see the ordering note below.

GET /shipping/phone_number additionally requires auth:sanctum — a Bearer token, on top of the app secret. That token is the one the other two endpoints mint.

auth:sanctum runs before the app-secret check

route:list prints the middleware as needs_app_secret then auth:sanctum, but Laravel's default middleware-priority list contains Illuminate\Auth\Middleware\Authenticate, so authentication sorts ahead of the unprioritised NeedsAppSecret regardless of the printed order. On GET /shipping/phone_number this inverts the debugging order you would assume — you only learn that the secret is wrong once you already hold a valid token:

RequestActual response
wrong secret, no bearer token401 {"message":"Unauthenticated."} — the bare Laravel shape, with no success key
wrong secret, valid bearer token500 {"errors":[],"message":"Invalid Secret Key","success":false}
correct secret, no bearer token401 {"message":"Unauthenticated."}

Two different envelopes

Successes use the four-key { data, message, success, warning } envelope. Failures on this group use the failure helper's three-key shape { errors, message, success } — there is no data and no warning key on a failure here. A 401 from auth:sanctum uses neither: it is Laravel's own {"message":"Unauthenticated."}, with no success key at all.

Framework messages come back in the server's locale

This route group carries no localization middleware, so Accept-Language has no effect (tested with Accept-Language: en). Every framework-generated message and errors entry arrives in the server's locale — Hebrew — for example "שדה phone number הוא חובה." rather than "The phone number field is required.". Never match on message text: branch on the HTTP status and on the field keys inside errors.


GET /get_app_user ​

Look a recipient up by phone number and mint a token for the calling device. This is the recipient app's sign-in: there is no password, and possession of the app secret plus a known phone number is what authorises it.

A token is created on every successful call, named after device_name. Previously issued tokens are not revoked.

Parameters ​

Query ​

FieldTypeRequiredDescription
phone_numberstringyesThe recipient's phone number, matched exactly against the stored phone.
device_namestringyesName for the personal access token being created. Max 255 characters.

Example request ​

bash
curl --location --get 'https://app.shipos.co.il/api/get_app_user' \
--header 'X-App-Secret: {app_secret}' \
--header 'Accept: application/json' \
--data-urlencode 'phone_number=0501234567' \
--data-urlencode 'device_name=Dana iPhone 15'
js
// Node.js 18+ / browsers — no dependencies
const query = new URLSearchParams({
  phone_number: '0501234567',
  device_name: 'Dana iPhone 15',
})

const response = await fetch(
  `https://app.shipos.co.il/api/get_app_user?${query}`,
  {
    headers: {
      'X-App-Secret': process.env.SHIPOS_APP_SECRET,
      Accept: 'application/json',
    },
  },
)

const body = await response.json()

if (!body.success) {
  throw new Error(`ShipOS v1: ${body.message}`) // "User not found"
}

const token = body.data.token // use as Bearer on shipping/phone_number
php
<?php

use Illuminate\Support\Facades\Http;

$body = Http::withHeaders(['X-App-Secret' => config('services.shipos.app_secret')])
    ->acceptJson()
    ->get('https://app.shipos.co.il/api/get_app_user', [
        'phone_number' => '0501234567',
        'device_name' => 'Dana iPhone 15',
    ])
    ->json();

if (! ($body['success'] ?? false)) {
    throw new RuntimeException('ShipOS v1: '.$body['message']);
}

$token = $body['data']['token'];

Example response ​

json
{
  "data": {
    "id": 4412,
    "name": "Dana Levi",
    "phone": "0501234567",
    "created_at": "2026-03-04T09:12:00.000000Z",
    "updated_at": "2026-03-04T09:12:00.000000Z",
    "token": "412|k7Yt..."
  },
  "message": "",
  "success": true,
  "warning": ""
}

Response fields ​

FieldTypeDescription
idintegerRecipient id.
namestringRecipient name.
phonestringRecipient phone number. Unique across recipients.
created_atstringISO 8601 timestamp.
updated_atstringISO 8601 timestamp.
tokenstringPlain-text Sanctum personal access token, shown only in this response. Send it as Authorization: Bearer ….

Errors ​

StatusWhen
422phone_number or device_name missing, or device_name longer than 255 characters. message is the first validation message; errors is the full field-keyed map.
500No recipient with that phone number (message: "User not found", errors: []), or a wrong X-App-Secret (message: "Invalid Secret Key").

POST /create_app_user ​

Register a recipient and mint the first token for the calling device. The only endpoint in the group that takes a body.

Parameters ​

Body ​

FieldTypeRequiredDescription
namestringyesRecipient name. Max 255 characters.
phone_numberstringyesRecipient phone number. Must be unique among recipients — registering a phone that already exists is a validation failure, not a sign-in. Max 255 characters. Stored as phone.
device_namestringyesName for the personal access token being created. Max 255 characters.

Example request ​

bash
curl --location 'https://app.shipos.co.il/api/create_app_user' \
--header 'X-App-Secret: {app_secret}' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
    "name": "Dana Levi",
    "phone_number": "0501234567",
    "device_name": "Dana iPhone 15"
}'
js
// Node.js 18+ / browsers — no dependencies
const response = await fetch('https://app.shipos.co.il/api/create_app_user', {
  method: 'POST',
  headers: {
    'X-App-Secret': process.env.SHIPOS_APP_SECRET,
    'Content-Type': 'application/json',
    Accept: 'application/json',
  },
  body: JSON.stringify({
    name: 'Dana Levi',
    phone_number: '0501234567',
    device_name: 'Dana iPhone 15',
  }),
})

const body = await response.json()

if (!body.success) {
  // 422 when the phone number is already registered
  throw new Error(`ShipOS v1: ${body.message}`)
}

const token = body.data.token
php
<?php

use Illuminate\Support\Facades\Http;

$body = Http::withHeaders(['X-App-Secret' => config('services.shipos.app_secret')])
    ->acceptJson()
    ->post('https://app.shipos.co.il/api/create_app_user', [
        'name' => 'Dana Levi',
        'phone_number' => '0501234567',
        'device_name' => 'Dana iPhone 15',
    ])
    ->json();

if (! ($body['success'] ?? false)) {
    throw new RuntimeException('ShipOS v1: '.$body['message']);
}

$token = $body['data']['token'];

Example response ​

Identical in shape to get_app_user.

json
{
  "data": {
    "id": 4413,
    "name": "Dana Levi",
    "phone": "0501234567",
    "created_at": "2026-08-31T08:40:11.000000Z",
    "updated_at": "2026-08-31T08:40:11.000000Z",
    "token": "413|9pQx..."
  },
  "message": "",
  "success": true,
  "warning": ""
}

A validation failure — here a missing phone_number. Note that the message is in the server's locale, not yours:

json
{
  "errors": {
    "phone_number": ["שדה phone number הוא חובה."]
  },
  "message": "שדה phone number הוא חובה.",
  "success": false
}

Errors ​

StatusWhen
422Any field missing, over 255 characters, or a phone_number that already exists.
500Wrong or missing X-App-Secret (message: "Invalid Secret Key").

GET /shipping/phone_number ​

List the shipments addressed to the authenticated recipient's phone number, paginated. Despite the path, there is no phone parameter — the phone comes from the token.

Requires both X-App-Secret and Authorization: Bearer {token}, the token being one minted by get_app_user or create_app_user. Shipments are matched on the shipment's own phone column against the token owner's phone.

Parameters ​

Query ​

FieldTypeRequiredDescription
per_pageintegernoPage size. Defaults to 100.
pageintegernoPage number, the standard Laravel paginator parameter.
searchstringnoSubstring match across recipient first/last name, address lines, city, and shipping code, plus the order's customer name, order id and order number.
order_idstringnoSubstring match on the order's order_id or order_number.
namestringnoSubstring match on the shipment's first or last name.
addressstringnoSubstring match on address line 1, address line 2 or city.
phonestringnoSubstring match on the shipment's own phone column or on the order's billing JSON blob. Narrows within the recipient's own shipments; it cannot widen the set.
shipping_codestringnoSubstring match on the carrier tracking code.
search_byobjectnoNested form of five of the filters above, honoured alongside them: search_by[order_id], search_by[name], search_by[address], search_by[phone], search_by[shipping_code]. Each behaves like its flat counterpart, and an empty or falsy value is skipped.
customerintegernoRestrict to shipments whose order belongs to this customer id.
companyintegernoRestrict to shipments whose order's license belongs to this company id.
licenseintegernoRestrict to shipments on this license id — matched on the shipment's own license_id or on its order's.
flagintegernoRestrict to shipments carrying this flag id.
typestringnocod restricts to cash-on-delivery shipments (shipment_data.is_cod true); independent restricts to shipments on the Independent provider; any other value is matched against the shipment's type column and additionally excludes cash-on-delivery shipments.
sort_by_warehousestringnoasc or desc, defaulting to desc. Orders results by warehouse location, and is only honoured when the authenticated account has warehouse sorting enabled.
statusstringnoSubstring match on the shipment's stored status text.
fromstringnoOnly shipments created on or after this date.
tostringnoOnly shipments created on or before this date.
active_onlybooleannoWhen true, restricts to shipments with is_active set — i.e. not cancelled.
label_generatedbooleannoFilter on whether a label has been generated.
sort_bystringnoColumn to sort by, qualified against the shippings table. Must be sent together with sort_direction.
sort_directionstringnoasc or desc. Must be sent together with sort_by.

Without sort_by / sort_direction, results come back newest first (shippings.created_at descending).

Example request ​

bash
curl --location --get 'https://app.shipos.co.il/api/shipping/phone_number' \
--header 'X-App-Secret: {app_secret}' \
--header 'Authorization: Bearer {token}' \
--header 'Accept: application/json' \
--data-urlencode 'per_page=20'
js
// Node.js 18+ / browsers — no dependencies
const query = new URLSearchParams({ per_page: '20' })

const response = await fetch(
  `https://app.shipos.co.il/api/shipping/phone_number?${query}`,
  {
    headers: {
      'X-App-Secret': process.env.SHIPOS_APP_SECRET,
      Authorization: `Bearer ${token}`,
      Accept: 'application/json',
    },
  },
)

const body = await response.json()

if (!body.success) {
  throw new Error(`ShipOS v1: ${body.message}`)
}

console.log(body.data.client, body.data.meta.total)

for (const shipment of body.data.shippings) {
  console.log(shipment.shipping_id, shipment.shipping_status)
}
php
<?php

use Illuminate\Support\Facades\Http;

$body = Http::withHeaders(['X-App-Secret' => config('services.shipos.app_secret')])
    ->withToken($token)
    ->acceptJson()
    ->get('https://app.shipos.co.il/api/shipping/phone_number', [
        'per_page' => 20,
    ])
    ->json();

if (! ($body['success'] ?? false)) {
    throw new RuntimeException('ShipOS v1: '.$body['message']);
}

foreach ($body['data']['shippings'] as $shipment) {
    logger()->info($shipment['shipping_id'].' '.$shipment['shipping_status']);
}

Example response ​

json
{
  "data": {
    "client": "Dana Levi",
    "shippings": [
      {
        "orderId": "M85157304",
        "shipping_id": "171258",
        "shipping_status": "פעיל",
        "shipping_date": "2026-07-07T13:44:04.000000Z",
        "shipping_company": "Lionwheel test",
        "shipping_from": null,
        "shipping_from_logo": "https://app.shipos.co.il/storage/logos/lionwheel-test.png",
        "items": []
      }
    ],
    "meta": {
      "current_page": 1,
      "per_page": 20,
      "total": 3,
      "last_page": 1,
      "from": 1,
      "to": 3,
      "path": "https://app.shipos.co.il/api/shipping/phone_number",
      "first_page_url": "...",
      "last_page_url": "...",
      "next_page_url": null,
      "prev_page_url": null,
      "links": [ { "...": "..." } ]
    }
  },
  "message": "",
  "success": true,
  "warning": ""
}

Response fields ​

FieldTypeDescription
clientstringThe authenticated recipient's name.
shippingsarrayThe current page of shipments, each in the ShippingResource shape — exactly eight keys, listed below.
metaobjectThe Laravel paginator's own payload minus its data key — current_page, per_page, total, last_page, from, to, path, first_page_url, last_page_url, next_page_url, prev_page_url, links.

Each entry in shippings:

FieldTypeDescription
orderIdstringThe merchant's own order reference — the order's order_id, e.g. "M85157304".
shipping_idstringThe shipment's id, as a string. This is the ShipOS row id, not the carrier tracking code.
shipping_statusstringStatus text, in the server's locale — e.g. "פעיל".
shipping_datestringISO 8601 creation timestamp.
shipping_companystringDisplay name of the carrier the shipment went out with.
shipping_fromstring | nullSender / collection label. null on shipments that have none.
shipping_from_logostringAbsolute ShipOS storage URL for the logo.
itemsarrayThe shipment's line items. [] when the shipment has no products.

Each items entry carries item_name, item_price, item_price_formatted, item_quantity, item_total, item_total_formatted, item_image and item_currency.

shippings is the ShippingResource shape

The handler builds a ShippingResource collection on one line and returns $shippings->items() on the next. The first line looks like dead code but is load-bearing: ShippingResource::collection($shippings) mutates the paginator in place — Laravel's collectResource() calls setCollection() — so items() hands back the resource instances, not the model rows. Entries therefore carry exactly the eight keys above, confirmed identical across all 137 live rows. There is no id, uuid, first_name, city, order or products anywhere in the response.

Errors ​

StatusWhen
401Missing or invalid Authorization: Bearer token. Because auth:sanctum sorts ahead of the secret check, this is also what a wrong X-App-Secret returns while the token is missing. Bare Laravel shape — {"message":"Unauthenticated."}, no success key.
422per_page is not an integer.
500Wrong or missing X-App-Secret (message: "Invalid Secret Key") — only reachable once a valid bearer token is present.
500Attempt to read property "currency" on null — a server-side defect in ShippingResource, tripped by a shipment on the requested page that has products but a null order. See below.

The documented default can return HTTP 500

per_page really does default to 100, but a call with no parameters at all — the documented default — returned 500 on the live server: "Attempt to read property \"currency\" on null", thrown from app/Http/Resources/Api/ShippingResource.php. Three lines of the items map read $this->order->currency unguarded while the sibling fields correctly use ?->, so a single shipment that has products but an orphaned (null) order takes the whole page down. This is a known server-side defect, not a caller error: whether you hit it depends on the recipient's data, not on your request. Bisecting live on one recipient, per_page=7 returned 200 and per_page=8 returned 500 — so a small per_page can page around a bad row. It is not fixed.


v2 equivalent ​

None. API v2 authenticates a merchant with client credentials and scopes everything to that merchant's licenses; it has no recipient identity, no phone-number sign-in and no app-secret gate. There is nothing in v2 to migrate these three endpoints to.