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:
| Header | Required | Description |
|---|---|---|
X-App-Secret | yes | Must 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:
{
"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:
| Request | Actual response |
|---|---|
| wrong secret, no bearer token | 401 {"message":"Unauthenticated."} — the bare Laravel shape, with no success key |
| wrong secret, valid bearer token | 500 {"errors":[],"message":"Invalid Secret Key","success":false} |
| correct secret, no bearer token | 401 {"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
| Field | Type | Required | Description |
|---|---|---|---|
phone_number | string | yes | The recipient's phone number, matched exactly against the stored phone. |
device_name | string | yes | Name for the personal access token being created. Max 255 characters. |
Example request
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'// 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
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
{
"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
| Field | Type | Description |
|---|---|---|
id | integer | Recipient id. |
name | string | Recipient name. |
phone | string | Recipient phone number. Unique across recipients. |
created_at | string | ISO 8601 timestamp. |
updated_at | string | ISO 8601 timestamp. |
token | string | Plain-text Sanctum personal access token, shown only in this response. Send it as Authorization: Bearer …. |
Errors
| Status | When |
|---|---|
| 422 | phone_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. |
| 500 | No 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Recipient name. Max 255 characters. |
phone_number | string | yes | Recipient 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_name | string | yes | Name for the personal access token being created. Max 255 characters. |
Example request
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"
}'// 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
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.
{
"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:
{
"errors": {
"phone_number": ["שדה phone number הוא חובה."]
},
"message": "שדה phone number הוא חובה.",
"success": false
}Errors
| Status | When |
|---|---|
| 422 | Any field missing, over 255 characters, or a phone_number that already exists. |
| 500 | Wrong 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
| Field | Type | Required | Description |
|---|---|---|---|
per_page | integer | no | Page size. Defaults to 100. |
page | integer | no | Page number, the standard Laravel paginator parameter. |
search | string | no | Substring match across recipient first/last name, address lines, city, and shipping code, plus the order's customer name, order id and order number. |
order_id | string | no | Substring match on the order's order_id or order_number. |
name | string | no | Substring match on the shipment's first or last name. |
address | string | no | Substring match on address line 1, address line 2 or city. |
phone | string | no | Substring 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_code | string | no | Substring match on the carrier tracking code. |
search_by | object | no | Nested 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. |
customer | integer | no | Restrict to shipments whose order belongs to this customer id. |
company | integer | no | Restrict to shipments whose order's license belongs to this company id. |
license | integer | no | Restrict to shipments on this license id — matched on the shipment's own license_id or on its order's. |
flag | integer | no | Restrict to shipments carrying this flag id. |
type | string | no | cod 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_warehouse | string | no | asc or desc, defaulting to desc. Orders results by warehouse location, and is only honoured when the authenticated account has warehouse sorting enabled. |
status | string | no | Substring match on the shipment's stored status text. |
from | string | no | Only shipments created on or after this date. |
to | string | no | Only shipments created on or before this date. |
active_only | boolean | no | When true, restricts to shipments with is_active set — i.e. not cancelled. |
label_generated | boolean | no | Filter on whether a label has been generated. |
sort_by | string | no | Column to sort by, qualified against the shippings table. Must be sent together with sort_direction. |
sort_direction | string | no | asc 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
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'// 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
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
{
"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
| Field | Type | Description |
|---|---|---|
client | string | The authenticated recipient's name. |
shippings | array | The current page of shipments, each in the ShippingResource shape — exactly eight keys, listed below. |
meta | object | The 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:
| Field | Type | Description |
|---|---|---|
orderId | string | The merchant's own order reference — the order's order_id, e.g. "M85157304". |
shipping_id | string | The shipment's id, as a string. This is the ShipOS row id, not the carrier tracking code. |
shipping_status | string | Status text, in the server's locale — e.g. "פעיל". |
shipping_date | string | ISO 8601 creation timestamp. |
shipping_company | string | Display name of the carrier the shipment went out with. |
shipping_from | string | null | Sender / collection label. null on shipments that have none. |
shipping_from_logo | string | Absolute ShipOS storage URL for the logo. |
items | array | The 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
| Status | When |
|---|---|
| 401 | Missing 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. |
| 422 | per_page is not an integer. |
| 500 | Wrong or missing X-App-Secret (message: "Invalid Secret Key") — only reachable once a valid bearer token is present. |
| 500 | Attempt 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.