Authentication
The v1 API authenticates with a license key sent in the request payload. There are no credential headers, no token exchange and no session: every call to a shipping/* endpoint carries a license_key and a domain field, and the middleware resolves them into the License the request acts under.
This is not how v2 works
v2 authenticates the account with X-Client-Id / X-Client-Secret headers and selects the license separately with a license_key parameter. v1 fuses the two: the license key is the credential. If you are building something new, use v2 authentication instead.
Required fields
Send both fields on every shipping/* request. On the POST endpoints they belong in the JSON body; on the GET endpoints (same_day_delivery_quote, create_shipping_promise, get_express_delivery_enabled, get_express_delivery_status, user-by-license) they go in the query string.
| Field | Type | Required | Description |
|---|---|---|---|
license_key | string | yes | The License key. Identifies both the caller and the carrier account the operation runs against. |
domain | string | yes | The calling store's domain, e.g. your-store.example.com. Required by validation — see About domain for what is and is not checked. |
token | string | no | A Sanctum personal access token belonging to the ShipOS user that owns the license. Only consulted for premium-company licenses (below). |
The license_key is generated in ShipOS and copied into the store plugin's settings; there is no v1 endpoint that issues one.
Do not send a license field
The middleware merges the resolved License model into the request under the key license. Any license value you send is overwritten, so it can never be used to influence which account a request runs against — but do not rely on it surviving either.
What is verified
The hasLicense middleware runs before the endpoint's own validation, in this order:
- Payload validation —
license_keyanddomainmust both be present, non-empty strings. A miss is a422validation error, not a403. - Token resolution — if a
tokenfield is present it is looked up as a Sanctum personal access token and its owner becomes the acting user. With no token, the acting user is whoever the session authenticates — which, for an external API caller, is nobody. Atokenthat does not resolve is not an error by itself; it simply leaves the acting user null. - License lookup — the License row is found by
key. Unknown key → failure. - Active check — the License's
is_activeflag must be true. A deactivated license → failure. - Premium gating — if the License's company is flagged
is_premium, the request additionally requires an acting user (step 2) and that user's premium membership must be active. Either miss → failure.
On success the License is attached to the request and the endpoint proceeds under it. Everything the endpoint then touches — the customer, the company, the carrier provider and its settings — is derived from that License, so the license key alone determines which carrier account is charged.
There is no expiry check in this path: an unexpired-but-inactive license is rejected by step 4, and time-based expiry is not evaluated here.
About domain
domain is a required field, but the check that compared it against the License's stored domain is commented out in the current code — its value is validated for presence and then not used for authorisation. Keep sending the store's real domain (the field is required, it is recorded in the request log, and the check may be reinstated), but do not treat a matching domain as a security boundary.
The License's own domain column is populated the first time a store calls GET /api/license/activate_license, and only if it is still empty.
Failure responses
Every license failure is returned by the middleware as HTTP 403 in the standard v1 envelope, with data as an empty array and the reason in message:
{
"data": [],
"message": "License key has been deactivated",
"success": false,
"warning": ""
}| Condition | Status | message |
|---|---|---|
license_key or domain missing | 422 | Laravel validation body — see Errors |
No License with that key | 403 | Invalid License Key |
License is_active is false | 403 | License key has been deactivated |
| Premium company, no acting user resolved | 403 | Premium companies are not supported in this version of plugin |
| Premium company, acting user's premium is not active | 403 | You must be a premium member to use this company |
Two notes on the statuses:
- The unknown-key case is classified internally as a
404, but the middleware emits403for all license failures. Do not expect a404here. - The premium messages are passed through translation, so they can come back localised. Branch on the HTTP status and
success, never onmessage.
Worked example
Reading a shipment's stored details needs nothing but the credentials plus the tracking code:
curl --location 'https://app.shipos.co.il/api/shipping/get_order_details' \
--header 'Content-Type: application/json' \
--header 'Accept: 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: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
license_key: process.env.SHIPOS_LICENSE_KEY,
domain: process.env.SHIPOS_DOMAIN,
shipping_id: '66747921',
}),
})
const body = await response.json()
if (response.status === 403) {
// License rejected: unknown key, deactivated, or premium gating.
throw new Error(`ShipOS license rejected: ${body.message}`)
}
if (!response.ok || !body.success) {
throw new Error(`ShipOS v1: ${body.message}`)
}
console.log(body.data)<?php
use Illuminate\Support\Facades\Http;
$response = Http::acceptJson()
->post('https://app.shipos.co.il/api/shipping/get_order_details', [
'license_key' => config('services.shipos.license_key'),
'domain' => config('services.shipos.domain'),
'shipping_id' => '66747921',
]);
$body = $response->json();
if ($response->forbidden()) {
throw new RuntimeException('ShipOS license rejected: '.$body['message']);
}
if (! ($body['success'] ?? false)) {
throw new RuntimeException('ShipOS v1: '.$body['message']);
}
logger()->info('Shipment details', $body['data']);A license that resolves cleanly means the shipment lookup itself is next: v1 scopes every shipment query to the calling license, so a tracking code that belongs to another account behaves exactly like one that does not exist.
Multiple licenses
There is no account-wide view in v1. One request acts under exactly one license, chosen by the license_key you send, and a customer holding several carrier licenses picks the carrier by picking the key. Nothing in v1 lists the keys an account owns.
First-party callers
The middleware also recognises a secret field equal to the application key, which skips domain validation and the license checks above and loads the License by key directly. That path exists for ShipOS's own internal services; it is not available to integrators and is documented here only so its presence in request logs is not mistaken for a way in.