Skip to content

Licenses ​

A license is a carrier account. Its key is the credential every v1 shipping call carries. Three endpoints deal with the license itself: two that a plugin calls while a merchant is pasting a key into a settings screen, and one that resolves a key to the account behind it.

Three endpoints, two different auth models

check_license and activate_license live in the license route prefix, which has no middleware at all — no hasLicense, no request logging, no response normalisation. The key you send is not verified before the handler runs; verifying it is the operation. user-by-license lives in the shipping prefix and is therefore behind hasLicense like every other shipping endpoint, so an unusable key is rejected with HTTP 403 before its handler is reached.

EndpointPrefixhasLicenseBad key surfaces as
GET /license/check_licenselicensenonothing — HTTP 500 for every input today, see the known-defect box below
GET /license/activate_licenselicensenoHTTP 200, success: false
GET /shipping/user-by-licenseshippingyesHTTP 403, success: false

The practical consequence: neither of the first two returns a non-200 status by design, so a caller must branch on success rather than on the status. check_license is nonetheless a hard 500 on the current code whatever you send it — see the known-defect box below; activate_license behaves as documented. The third behaves like the rest of the shipping group.

A different envelope

None of the three returns the four-key { data, message, success, warning } envelope the rest of v1 uses. check_license and activate_license return { success, error, license, provider, message }; user-by-license returns { license, user, shopify_sites } with no success key at all.

Validation failures do not use the v1 envelope

All three endpoints return Laravel's own { message, errors } payload for a validation failure — not the four-key envelope, and not the { success, error, ... } shape above. The messages are localised: on the Israeli instances (APP_LOCALE=he) they come back in Hebrew.

json
{
  "message": "שדה domain הוא חובה.",
  "errors": { "domain": ["שדה domain הוא חובה."] }
}

Match on the 422 status and on the field keys inside errors. Never match on the message text.

Both license-prefix endpoints validate the same two fields, and validate nothing else:

FieldTypeRequiredDescription
domainstringyesThe calling store's domain.
license_keystringyesThe license key being checked or activated.

domain is required but not enforced

Validation rejects a request that omits domain, but the check that compared it against the license's stored domain is commented out in CheckLicense. A key therefore validates from any domain today. Do not design around that — send the real domain.


GET /license/check_license ​

Verify a license key. Used by the plugins to tell a merchant whether the key they pasted works, before anything is shipped with it.

What is actually checked, in order:

  1. A license row exists with that key — otherwise error: "missing", message: "Invalid License Key".
  2. The license is active (is_active) — otherwise error: "revoked", message: "License key has been deactivated".
  3. If the license's company is marked premium, the caller must resolve to a premium-active user — otherwise error: "invalid" with either Premium companies are not supported in this version of plugin (no user at all) or You must be a premium member to use this company.

Expiry is not part of this check, and neither is the domain (see above).

Known defect

checkLicense calls CheckLicense::check() with two arguments while that action declares three required parameters, so on the current code the call raises an ArgumentCountError before any of the checks above run — the endpoint returns a framework 500, not the envelope documented below. The response shapes here are what the handler is written to produce; treat the endpoint as unusable until the call is fixed. activate_license and user-by-license are not affected.

Parameters ​

Query ​

FieldTypeRequiredDescription
license_keystringyesThe license key to verify.
domainstringyesThe calling store's domain.

Example request ​

bash
curl --location --get 'https://app.shipos.co.il/api/license/check_license' \
--header 'Accept: application/json' \
--data-urlencode 'license_key={license_key}' \
--data-urlencode 'domain=your-store.example.com'
js
// Node.js 18+ / browsers — no dependencies
const query = new URLSearchParams({
  license_key: process.env.SHIPOS_LICENSE_KEY,
  domain: process.env.SHIPOS_DOMAIN,
})

const response = await fetch(
  `https://app.shipos.co.il/api/license/check_license?${query}`,
  { headers: { Accept: 'application/json' } },
)

// Once the defect is fixed: always HTTP 200 — branch on success, not the status
const body = await response.json()

if (!body.success) {
  throw new Error(`ShipOS v1: ${body.error} — ${body.message}`)
}
php
<?php

use Illuminate\Support\Facades\Http;

$body = Http::acceptJson()
    ->get('https://app.shipos.co.il/api/license/check_license', [
        'license_key' => config('services.shipos.license_key'),
        'domain' => config('services.shipos.domain'),
    ])
    ->json();

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

Example response ​

A valid key. provider is the whole providers row for the license's company: id, name, endpoints, created_at and updated_at, and nothing else — there is nothing sensitive in it.

endpoints is not a set of the carrier's own API URLs. The values are relative path fragments that get appended to the license's company service_url (companies.service_url), and for most providers they are empty strings.

json
{
  "success": true,
  "error": null,
  "license": "valid",
  "provider": {
    "id": 5,
    "name": "Cargo",
    "endpoints": { "shipping": "Webservice/", "label": "Webservice/", "tracking": "" },
    "created_at": "2023-02-16T07:14:18.000000Z",
    "updated_at": "2023-02-16T07:14:18.000000Z"
  },
  "message": "License key is valid"
}

A key that is not usable — still HTTP 200, and with no provider key:

json
{
  "success": false,
  "error": "missing",
  "license": "invalid",
  "message": "Invalid License Key"
}

Response fields ​

FieldTypeDescription
successboolThe authoritative signal.
errorstring | nullmissing (no such key), revoked (deactivated), invalid (premium requirement unmet), or null on success.
licensestringvalid or invalid. Redundant with success.
providerobjectThe carrier row for the license's company. Present on success only.
messagestringHuman-readable prose. Some values are localised, so do not branch on it.

Errors ​

StatusWhen
500Every request today, whether the key is valid, bogus or absent from the database — the ArgumentCountError above. This is not a documented outcome; it is the only observable one.
200Every documented outcome, success and failure alike — the intended behaviour, reachable only once the call is fixed.
422license_key or domain missing from the query string. Body is Laravel's plain { message, errors }, localised — see the box near the top of this page.

GET /license/activate_license ​

Bind a license key to the calling domain and stamp its start time. This is the second half of a merchant pasting a key: check_license reports, activate_license records.

What it does, exactly:

  1. Looks the license up by key. Not found → error: "missing", message: "Invalid License Key", HTTP 200.
  2. Sets domain to the supplied value only if the license has no domain yet. An already-bound license keeps its existing domain and the request still succeeds.
  3. Sets started_at to now — on every call, including repeat calls.
  4. Saves.

Activation performs no validation of the license

There is no is_active check, no expiry check and no premium check on this path — those live in check_license and in hasLicense. A deactivated or expired key activates successfully here and then fails on the first shipping call. Call check_license if you need to know whether a key is usable.

Saving fires the License::updated hook, which performs one synchronous outbound POST per connected WooCommerce site, inline in your request (wp-json/wc/v3/shipos_license_update_all). Two consequences, both verified live on an account with two connected sites: the request's latency grows with the number of connected sites, and a site that cannot be reached fails silently — one of the two failed DNS resolution and the failure was swallowed to Log::info while the caller still received a success response. Activation is therefore not a read-only probe: do not call it to test a key, and do not call it in a loop.

This write leaves no api_request_logs row. The license route prefix has no logging middleware, so the state change it performs has no audit trail there.

Also confirmed live: an already-set domain is not overwritten even when a different domain is sent, and started_at moves from null to now on every successful call.

Parameters ​

Query ​

FieldTypeRequiredDescription
license_keystringyesThe license key to activate.
domainstringyesThe domain to bind, used only when the license has no domain recorded.

Example request ​

bash
curl --location --get 'https://app.shipos.co.il/api/license/activate_license' \
--header 'Accept: application/json' \
--data-urlencode 'license_key={license_key}' \
--data-urlencode 'domain=your-store.example.com'
js
// Node.js 18+ / browsers — no dependencies
const query = new URLSearchParams({
  license_key: process.env.SHIPOS_LICENSE_KEY,
  domain: process.env.SHIPOS_DOMAIN,
})

const response = await fetch(
  `https://app.shipos.co.il/api/license/activate_license?${query}`,
  { headers: { Accept: 'application/json' } },
)

const body = await response.json()

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

console.log(body.provider.name)
php
<?php

use Illuminate\Support\Facades\Http;

$body = Http::acceptJson()
    ->get('https://app.shipos.co.il/api/license/activate_license', [
        'license_key' => config('services.shipos.license_key'),
        'domain' => config('services.shipos.domain'),
    ])
    ->json();

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

logger()->info('Activated on '.$body['provider']['name']);

Example response ​

json
{
  "success": true,
  "error": null,
  "license": "valid",
  "provider": {
    "id": 5,
    "name": "Cargo",
    "endpoints": { "shipping": "Webservice/", "label": "Webservice/", "tracking": "" },
    "created_at": "2023-02-16T07:14:18.000000Z",
    "updated_at": "2023-02-16T07:14:18.000000Z"
  },
  "message": "License key is valid"
}

Unknown key — HTTP 200:

json
{
  "success": false,
  "error": "missing",
  "license": "invalid",
  "message": "Invalid License Key"
}

message is the fixed string License key is valid on success; it does not report whether the domain was written or was already set.

Errors ​

StatusWhen
200Both outcomes, success and unknown key.
422license_key or domain missing from the query string. Body is Laravel's plain { message, errors }, localised — see the box near the top of this page.

GET /shipping/user-by-license ​

Resolve a license key to the license, the customer who owns it, and that customer's connected Shopify sites. Used by first-party surfaces that hold a key and need the account behind it.

Because this endpoint is in the shipping prefix, hasLicense has already validated the key by the time the handler runs, and an unusable key produced a 403 with the standard four-key envelope. See Authentication.

This endpoint returns the account's API credentials

The response embeds the full license row and the full users row for the owning customer — 48 keys on the live instance. password, remember_token and the two-factor columns are genuinely absent, hidden by the model. Everything else is present, including:

  • client_id and client_secret — the v2 API credential pair, the same values a v2 caller sends as X-Client-Id / X-Client-Secret.
  • google_token, google_refresh_token, google_api_key, geoapify_api_key — third-party credentials held for the account.
  • api_capabilities and payment.

Anyone holding a v1 license key can read that account's v2 API credentials and its third-party API keys from this endpoint. On the license this page was verified against, client_id and client_secret happened to be null while google_api_key was populated with a live key — but the fields are always present in the payload, so any account that has had v2 credentials issued exposes them here.

The same body is also written to the database: the full response, license key included, is persisted verbatim to api_request_logs.normalized_response, so these secrets land in the log table too.

Never proxy this response to a browser, and treat any stored copy of it — including the log row — as a secret.

Premium companies: 403 for a plain caller, 500 past that gate

A premium license sent with only the two documented parameters never reaches the handler at all. hasLicense rejects it with HTTP 403 and message Premium companies are not supported in this version of plugin. That is what a plain caller actually sees.

A caller that gets past hasLicense as a premium-active user — i.e. one supplying a token that resolves to such a user — hits the second problem: the handler re-runs the license check with no user, even though hasLicense has just run it with one. For a premium company that second check fails, so no license comes back from it, and the handler then reads the customer off a license that is not there — the request ends as a 500. That branch is derived from the code and was not reproduced over HTTP. Non-premium licenses are unaffected either way.

Parameters ​

Query ​

FieldTypeRequiredDescription
license_keystringyesThe license key to resolve.
domainstringyesThe calling store's domain.
tokenstringnoSanctum personal access token. hasLicense reads it on every request; it only changes the outcome when the license's company is premium, where a token resolving to a premium-active user is what the check requires.

Example request ​

bash
curl --location --get 'https://app.shipos.co.il/api/shipping/user-by-license' \
--header 'Accept: application/json' \
--data-urlencode 'license_key={license_key}' \
--data-urlencode 'domain=your-store.example.com'
js
// Node.js 18+ / browsers — no dependencies
const query = new URLSearchParams({
  license_key: process.env.SHIPOS_LICENSE_KEY,
  domain: process.env.SHIPOS_DOMAIN,
})

const response = await fetch(
  `https://app.shipos.co.il/api/shipping/user-by-license?${query}`,
  { headers: { Accept: 'application/json' } },
)

if (!response.ok) {
  const { message } = await response.json() // 403 envelope
  throw new Error(`ShipOS v1: ${message}`)
}

// No success key on this response — a 200 is the success signal
const { license, user, shopify_sites: shopifySites } = await response.json()

console.log(user.email, license.company_id, shopifySites.length)
php
<?php

use Illuminate\Support\Facades\Http;

$response = Http::acceptJson()
    ->get('https://app.shipos.co.il/api/shipping/user-by-license', [
        'license_key' => config('services.shipos.license_key'),
        'domain' => config('services.shipos.domain'),
    ]);

if ($response->failed()) {
    throw new RuntimeException('ShipOS v1: '.$response->json('message'));
}

$body = $response->json();

logger()->info('License owner', ['user_id' => $body['user']['id']]);

Example response ​

json
{
  "license": {
    "id": 812,
    "key": "{license_key}",
    "name": "Cargo — main store",
    "domain": "your-store.example.com",
    "is_active": true,
    "type_id": 2,
    "company_id": 3391,
    "user_id": 5501,
    "created_by": 1,
    "commitment_day": 3,
    "multiple_shipment": 0,
    "started_at": "2026-01-31T00:00:00.000000Z",
    "expires_at": "2027-01-31T00:00:00.000000Z",
    "created_at": "2026-01-31T00:00:00.000000Z",
    "updated_at": "2026-07-01T10:22:14.000000Z",
    "deleted_at": null,
    "is_expired": false,
    "is_expiring_soon": false,
    "company": {
      "id": 3391,
      "name": "...",
      "service_url": "https://carrier.example.co.il/api/",
      "provider_id": 5,
      "is_premium": 0,
      "license_quota": 10,
      "available_licenses_count": -49,
      "...": "..."
    },
    "customer": {
      "id": 5501,
      "name": "...",
      "email": "...",
      "client_id": null,
      "client_secret": null,
      "google_api_key": "...",
      "shopify_sites": [ { "...": "..." } ],
      "...": "..."
    }
  },
  "user": {
    "id": 5501,
    "name": "...",
    "email": "...",
    "client_id": null,
    "client_secret": null,
    "google_api_key": "...",
    "shopify_sites": [ { "...": "..." } ],
    "...": "..."
  },
  "shopify_sites": [ { "...": "..." } ]
}

The license object has 20 keys, and two of them are whole nested objects. This is deterministic, not incidental: the hasLicense middleware touches $license->company and the handler touches $license->customer->shopify_sites, so all three relations are loaded and serialised on every successful call.

  • license.company is the entire companies row — service_url, provider_id, is_premium, license_quota and the rest. available_licenses_count is a computed attribute and it can be negative: -49 was observed on a live account that had issued far more licenses than its license_quota. Clamp it at zero before showing it as "licenses remaining".
  • license.customer is the entire owning user row again, duplicating the top-level user key — credential fields and all, see the box above — and it carries its own nested shopify_sites.
  • So shopify_sites appears three times in one response: as the top-level key, on the top-level user object, and again on license.customer. Read the top-level key and ignore the copies.

Response fields ​

FieldTypeDescription
licenseobjectThe licenses row — 20 keys. is_expired and is_expiring_soon are computed attributes the model always appends; is_active, started_at and expires_at are cast. Two of the keys are nested objects that are always present: company (the whole companies row, including a computed available_licenses_count that can be negative) and customer (the whole owning users row again, itself carrying shopify_sites).
userobjectThe users row for the license's customer, minus the hidden credential columns.
shopify_sitesarrayThe customer's connected Shopify sites. Empty when there are none.

Errors ​

StatusWhen
403hasLicense rejected the key — unknown, deactivated, or a premium company without a premium-active user. This is what a plain caller sending only license_key and domain gets for a premium license. Body is the standard four-key envelope with success: false.
422license_key or domain missing. Body is Laravel's plain { message, errors }, localised — see the box near the top of this page.
500The license's company is marked premium and the caller got past hasLicense as a premium-active user, e.g. by supplying a token (see the warning above). Code-derived — not reproduced over HTTP; a plain caller sees the 403 instead.

v2 equivalent ​

GET /licenses lists every license the authenticating customer owns, with its company, carrier and per-provider settings — and it returns the license key, so it replaces user-by-license for the common "which account is this?" case. It authenticates with X-Client-Id / X-Client-Secret headers rather than a key in the payload.

There is no v2 equivalent of check_license or activate_license: v2 has no concept of activating a key against a domain, and a bad credential is simply a 401.