Skip to content

Migrating from API v1 — API v2

Existing integrations speak the V1 REST API: /api/inventory without a version prefix, three X_REST_* credentials, field codes like I4201. API v2 lives under /api/v2, authenticates with a Bearer token and answers in JSON:API format. This page lists what a V1 client has to touch when migrating — and what deliberately keeps working.

The short version: field names stay — V1 codes remain valid identifiers. Keys keep their value, MTAG and CTAG are now called id (details). URLs, authentication, filter syntax and the response envelope change.

Endpoints

V1 uses singular paths without a version, V2 plural paths under /api/v2:

V1 V2
GET /api/inventory GET /api/v2/inventories
GET /api/calibration GET /api/v2/calibrations
GET /api/customer GET /api/v2/customers
GET /api/location GET /api/v2/locations
GET /api/repair GET /api/v2/repairs
GET /api/contact GET /api/v2/contacts
GET /api/standard GET /api/v2/calibrations/{id}/standards (catalogue: /api/v2/test-standards)
GET /api/result GET /api/v2/calibrations/{id}/results
POST /api/result/update (bulk) PUT /api/v2/calibrations/{id}/results
GET/POST /api/report/{id} GET /api/v2/calibrations/{id}/reports, POST …/reports/generate
GET /api/document/{id} GET /api/v2/documents/{id} (file: …/download)
GET /api/user / group / role GET /api/v2/users / groups / roles
GET /api/app/version GET /api/v2/app/version
POST /api/auth/login POST /api/v2/auth/token (issues a Bearer token)
GET /api/{resource}/rules GET /api/v2/fields/{table}
GET /api/{resource}/get/{field}/{value} GET /api/v2/…?filter[field]=value
POST /api/{resource}/insert regular POST /api/v2/…
PUT /api/{resource}/{id} / POST /api/{resource}/update regular PUT /api/v2/…/{id}

IDs: MTAG, CTAG and the other tags

In V1 every record is keyed by a tag: MTAG on a device, CTAG on a calibration, KTAG on a customer. In V2 that field is called id everywhere. The value does not change, the data migration writes every tag into the new column unaltered. An MTAG a legacy system stored still points at the same device in V2.

V1 tag Record V2
MTAG Device id of /api/v2/inventories
CTAG Calibration id of /api/v2/calibrations
KTAG Customer id of /api/v2/customers
LTAG Location id of /api/v2/locations
RTAG Repair id of /api/v2/repairs

When a tag points at a different record it is a foreign key, and V2 names it after its target: the MTAG on a calibration becomes inventory_id, the KTAG becomes customer_id.

The rule for this whole section:

Tag as the record's own key → id. Tag as a reference to something else → …_id.

In a response the id sits next to type, one level above the fields:

{
  "data": {
    "id": "ea79f4c2-1b3d-4c88-9f21-7c0a5d13e001",
    "type": "inventory",
    "attributes": { "asset_number": "PM-100", "manufacturer": "FLUKE" }
  }
}

Fetching a device by its MTAG

MTAG=ea79f4c2-1b3d-4c88-9f21-7c0a5d13e001

# by key
curl "https://…/api/inventory/$MTAG" -H "X-REST-USERNAME: …" …

# or through the getter
curl "https://…/api/inventory/get/MTAG/$MTAG" …
MTAG=ea79f4c2-1b3d-4c88-9f21-7c0a5d13e001

# same value, now the id in the path
curl "https://…/api/v2/inventories/$MTAG" -H "$AUTH"

# or as a filter
curl "https://…/api/v2/inventories?filter[id]=$MTAG" -H "$AUTH"

Several devices at once

curl "https://…/api/inventory/get/MTAGs/$MTAG,bb12c7de-…" …
curl "https://…/api/v2/inventories?filter[id]=in:$MTAG,bb12c7de-…" -H "$AUTH"

Fetching a calibration by its CTAG

CTAG=3b17ad90-88c2-4e15-b0aa-2f9e6c4471aa

curl "https://…/api/calibration/$CTAG" …

# measurements included
curl "https://…/api/calibration/get/CTAG/$CTAG?getResult=1" …
CTAG=3b17ad90-88c2-4e15-b0aa-2f9e6c4471aa

curl "https://…/api/v2/calibrations/$CTAG" -H "$AUTH"

# measurements are their own sub-resource
curl "https://…/api/v2/calibrations/$CTAG/results" -H "$AUTH"

All calibrations of one device

curl "https://…/api/calibration/get/MTAG/$MTAG" …
# as a sub-resource of the device
curl "https://…/api/v2/inventories/$MTAG/calibrations" -H "$AUTH"

# or as a filter on the foreign key
curl "https://…/api/v2/calibrations?filter[inventory_id]=$MTAG" -H "$AUTH"

# the V1 code works here too: inventory_id is a registry field with v1_name MTAG
curl "https://…/api/v2/calibrations?filter[MTAG]=$MTAG" -H "$AUTH"

The same pattern applies to /api/v2/inventories/{mtag}/locations, /api/v2/inventories/{mtag}/repairs and /api/v2/customers/{ktag}/inventories.

One pitfall

filter[MTAG] on /api/v2/inventories returns every device

A filter on a field V2 does not know at that endpoint is skipped silently. The response is then 200 with the complete inventory, not 404 and not 422. A client that simply takes data[0] works on the wrong record from there on.

curl "https://…/api/v2/inventories?filter[MTAG]=$MTAG" -H "$AUTH"
# 200, "meta": { "total": 12480 }   ← every device, filter discarded

curl "https://…/api/v2/inventories?filter[id]=$MTAG" -H "$AUTH"
# 200, "meta": { "total": 1 }       ← correct

This hits exactly the case where the tag means the record's own key: filter[MTAG] on /inventories, filter[CTAG] on /calibrations, filter[KTAG] on /customers and so on. As a foreign key (filter[MTAG] on /calibrations) the very same code works fine.

Fix: use filter[id]=…, or the path /api/v2/inventories/{mtag}.

Sanity check for any filter: compare meta.total with and without the filter. The same number twice means the filter was discarded.

Cheat sheet

Goal V1 V2
Device by key GET /api/inventory/{MTAG} GET /api/v2/inventories/{MTAG}
Device by key, as a filter filter=[{"property":"MTAG","value":"…"}] filter[id]=…
Several devices GET /api/inventory/get/MTAGs/a,b filter[id]=in:a,b
Calibration by key GET /api/calibration/{CTAG} GET /api/v2/calibrations/{CTAG}
Measurements of a calibration GET /api/calibration/get/CTAG/{CTAG}?getResult=1 GET /api/v2/calibrations/{CTAG}/results
Calibrations of a device GET /api/calibration/get/MTAG/{MTAG} GET /api/v2/inventories/{MTAG}/calibrations
Customer by key GET /api/customer/{KTAG} GET /api/v2/customers/{KTAG}
Devices of a customer filter=[{"property":"ktag","value":"…"}] GET /api/v2/customers/{KTAG}/inventories

Authentication

V1 accepts the three credentials as headers or query parameters. V2 switches to Bearer tokens:

# Issue a token (once, or on expiry)
curl -X POST "https://…/api/v2/auth/token" \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "…", "device_name": "sync-tool"}'

# Then: token in the Authorization header
curl "https://…/api/v2/inventories" -H "Authorization: Bearer {token}"

As a transition, V2 keeps accepting the V1 credentials — as HTTP headers, all three together:

curl "https://…/api/v2/inventories" \
  -H "X-REST-USERNAME: user@example.com" \
  -H "X-REST-PASSWORD: …" \
  -H "X-REST-API-KEY: …"

What no longer works

Credentials as query parameters (?HTTP_X_REST_USERNAME=…) and authentication via an existing browser session are not accepted by V2. Clients that ship credentials in the URL must switch to headers or Bearer tokens. The X-REST header auth is deprecated and scheduled for removal in 7.0 — new integrations should start with Bearer tokens directly.

Response format

V1 answers with its own envelope, V2 with JSON:API:

{
  "success": true,
  "message": "Record(s) Found",
  "data": {
    "totalCount": 42,
    "inventory": [
      { "MTAG": "ea79…", "I4201": "PM-100", "I4202": "FLUKE" }
    ]
  }
}
{
  "data": [
    {
      "id": "ea79…",
      "type": "inventory",
      "attributes": { "asset_number": "PM-100", "manufacturer": "FLUKE" }
    }
  ],
  "meta": { "total": 42, "page": 1, "per_page": 25, "last_page": 2 }
}

Errors arrive as RFC 7807 problem JSON (Content-Type: application/problem+json, status code + detail) or — for domain conflicts — as {"error": {"status", "title", "detail"}}; there is no success flag any more, the HTTP status is authoritative.

Transition: requesting the V1 envelope

With Accept: application/vnd.calserver.v1+json, V2 reshapes the response envelope into the V1 format (success/message/totalCount). This only affects the envelope, not the field names — and it is deprecated (removal in 7.0). For field names see V1 field names.

Filtering and sorting

V1 takes filters as a JSON array, V2 as query parameters per field:

# Default operator is LIKE (partial match)
curl "https://…/api/inventory?filter=[{\"property\":\"I4201\",\"value\":\"PM-1\"}]" …
curl "https://…/api/inventory?sort=[{\"property\":\"I4201\",\"direction\":\"DESC\"}]" …
# Default is exact equality — partial matches explicitly with like:
curl "https://…/api/v2/inventories?filter[asset_number]=like:PM-1" -H "$AUTH"
curl "https://…/api/v2/inventories?sort=-asset_number" -H "$AUTH"

Migration trap: LIKE vs. exact

Without an operator, V1 filters with LIKE (partial match); V2 without a prefix matches exactly. A filter carried over 1:1 finds less in V2 — partial matches need the like: prefix.

Operator prefixes in V2: like:, notlike:, contains:, ncontains:, startswith:, endswith:, ne:, gt:, gte:, lt:, lte:, in:a,b,c, between:a,b, empty, nempty. Multiple conditions per field as an array (filter[field][]=…&filter[field][]=…, AND-joined), filter_join[field]=or switches to OR, filter_cs[field]=1 forces case sensitivity. Sorting: sort=field ascending, sort=-field descending. Field names may be the V1 code everywhere (filter[I4201]=…).

Pagination

V1 V2
Parameters limit / offset page[size] / page[number]
Default 100 records 25 records
Maximum limit=all (everything) page[size]=100
curl "https://…/api/v2/inventories?page[size]=100&page[number]=2" -H "$AUTH"

The total count lives in meta.total (V1: data.totalCount). There is no limit=all equivalent — fetch large datasets page by page.

Field names

Nothing to do: the V1 codes (I4201, C2301, K4601, …) remain valid identifiers in filters, sorting and writes, and on migrated installations they are even the factory default of the API name. Responses carry the codes additionally on request (?include_v1_names=1 or header X-CalServer-V1-Field-Names: 1). Details and edge cases: V1 field names. The complete mapping of an installation is served by GET /api/v2/fields/{table} (attributes api_name, v1_name).