Quickstart
Goes from "I have an API key" to "I called the TrakRF API and got a 200 back" in about ten minutes, with nothing but an HTTP client. If you don't have a key yet, mint one from the SPA's Account menu → API Keys → New Key (detail) and come back.
/docs/getting-started/api is the User Guide front-door for the API track. Its §1 "Pick your environment" and §2 "Make your first call" mirror the first two sections of this page near-verbatim — if you've already worked through them, skim or skip to §3 Round-trip: create, read, update, delete. The two pages are intentionally two front doors (User Guide track vs. API reference); the overlap is by design.
If you're already familiar with API-key-authenticated REST APIs, the TL;DR is: send your key as Authorization: Bearer <jwt> and hit $BASE_URL/api/v1/.... The full walkthrough follows.
1. Pick your environment
You're reading preview docs. Examples on this page target https://app.preview.trakrf.id — the app host that matches this docs site.
If your account lives on the other environment, use the switcher above — the examples and links below will update.
export BASE_URL=https://app.preview.trakrf.id
Preview-scoped keys will not authenticate against production and vice versa; the switcher above keeps the curl snippets aligned with whichever world your key was minted on.
2. Verify your key works
If you don't already have an API key, sign in to the preview app, open the Account menu → API Keys → New Key, name the key, and submit. The full JWT is shown once at creation — copy it immediately; it cannot be shown again. Full walkthrough (scope picker, expiration, organization switcher): Authentication → Mint your first API key.
Minting an API key requires an interactive browser session — there is no programmatic mint endpoint by design. CI/headless setups should mint a key out-of-band, paste it into a secret store (env var, vault, GitHub Actions secret), and reference it from there. See Authentication → Where keys come from for the design rationale and a contact path if your use case genuinely needs programmatic provisioning.
Save the key to an environment variable for the rest of this page:
export TRAKRF_API_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
GET /api/v1/orgs/me (getCurrentOrg in generated clients) returns the organization the API key is scoped to. It's the canonical "tell me about myself" endpoint, requires no specific scope, and confirms end-to-end that your key authenticates against the right environment. Use an API key here — /orgs/me rejects session JWTs from the web app, even though most other public endpoints accept them. See Private endpoints → Response shape: /orgs/me for the precise rule.
curl -H "Authorization: Bearer $TRAKRF_API_KEY" \
"$BASE_URL/api/v1/orgs/me"
A successful response:
{
"data": {
"id": 42,
"name": "Acme Logistics",
"scopes": [
"assets:read",
"assets:write",
"locations:read",
"locations:write",
"tracking:read"
],
"api_key_id": "550e8400-e29b-41d4-a716-446655440000"
}
}
If the name matches the organization you minted the key under, you're authenticated end-to-end and ready to call anything else on the surface. The scopes array echoes the scope strings on the bearer — useful for diagnosing a later 403 forbidden without decoding the JWT locally — and api_key_id is the UUID of the API key (matches the JWT's sub claim), worth quoting in any support ticket. Per-field detail: Private endpoints → Response shape: /orgs/me.
Troubleshooting:
401 unauthorized— the key is missing, malformed, revoked, or scoped to the other environment (preview vs production). Re-check theAuthorizationheader and the Base URL.401 unauthorizedwithdetail: "Use Authorization: Bearer <token>"— the JWT was sent underX-API-Key(or another header). The server only accepts theAuthorization: Bearerform, despite the credential being called an "API key." See Authentication → Request header.429 rate_limited— you're over budget; wait theRetry-Afterseconds. See Rate limits.- Browser console error with no response body — CORS. The API is server-to-server only; call it from a backend. See Server-to-server design.
Every error response is wrapped in an error key — a 401 looks like:
{
"error": {
"type": "unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing authorization header",
"instance": "/api/v1/orgs/me",
"request_id": "01JXXXXXXXXXXXXXXXXXXXXXXX"
}
}
Write error handlers against body.error.type and body.error.detail, not top-level fields. Full catalog: Errors.
3. Round-trip: create, read, update, delete
This walks through the write path end-to-end with an asset. It needs assets:write on the key (and assets:read if you want to verify via GET) — in the New API Key dialog, that's the Assets dropdown set to Read + Write. If your key was minted without those scopes, mint a new one — scopes can't be edited after creation. For the other read endpoints you'll see referenced elsewhere on the docs site, you'll want locations:read (Locations → Read) and tracking:read (Tracking → Read); see Authentication → Scopes and UI labels vs scope strings for the full matrix.
# Create — capture the assigned id from the response
ASSET=$(curl -s -X POST "$BASE_URL/api/v1/assets" \
-H "Authorization: Bearer $TRAKRF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_key": "ASSET-QUICKSTART",
"name": "Quickstart test asset"
}')
ASSET_ID=$(echo "$ASSET" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['id'])")
# Or, if jq is installed: ASSET_ID=$(echo "$ASSET" | jq -r '.data.id')
# Read it back by canonical id
curl -H "Authorization: Bearer $TRAKRF_API_KEY" \
"$BASE_URL/api/v1/assets/$ASSET_ID"
# Update — partial merge-patch per RFC 7396. Only fields in the body are
# changed; omitted fields are left unchanged. Send `null` to clear a
# nullable field; `{}` is a documented no-op.
curl -X PATCH "$BASE_URL/api/v1/assets/$ASSET_ID" \
-H "Authorization: Bearer $TRAKRF_API_KEY" \
-H "Content-Type: application/merge-patch+json" \
-d '{"name": "Quickstart test asset (renamed)"}'
# Delete
curl -X DELETE "$BASE_URL/api/v1/assets/$ASSET_ID" \
-H "Authorization: Bearer $TRAKRF_API_KEY"
openapi-fetch?openapi-fetch is schema-agnostic at runtime — it won't read application/merge-patch+json from the spec, and every PATCH returns 415 unsupported_media_type unless you override Content-Type per call or register a middleware. See §5 — TypeScript with openapi-fetch below for the drop-in middleware that handles every PATCH site automatically.
Each request echoes back the resource (or 204 No Content on delete) wrapped in the standard { "data": ... } envelope. The path-param routes take the canonical integer id; if you only have the natural key, filter the list endpoint first with GET /api/v1/assets?external_key=ASSET-QUICKSTART and read id off .data[0] (empty array on a miss). Full convention: Resource identifiers.
Round-trip: GET → mutate → PATCH
The minimal-form PATCH above sent only the field being changed. When your integration's data model mirrors the read shape one-to-one — you have an in-memory asset object you've been mutating — you'll want to send the whole thing back instead. Every read-only field on the read shape (server-managed id / created_at / updated_at / deleted_at, the tags collection, and the resource's own external_key) follows an accept-if-matches, reject-if-differs rule on PATCH, so echoing them straight back from a GET response is safe. No client-side scrubbing required. (On locations, both parent_id and parent_external_key are fully writable on PATCH — see the re-parent paragraph below.)
When you cache a GET response and PATCH later, updated_at is the field that drifts on every accepted PATCH — the server advances it to the current time on every successful write, regardless of whether the body contained real mutations, matched-value echoes, or an empty {}. A real intervening write by another caller is the case to plan for: the cached updated_at is now stale, so re-GET immediately before PATCH to refresh it, or strip it from the cached body before sending. This is optimistic concurrency working as designed — the accept-if-matches rule on updated_at is how the API detects lost updates from concurrent edits, and the always-advance behavior matches filesystem touch semantics (any successful write advances the modification time). For the lost-update detection pattern and a worked example, see Design notes → updated_at is an optimistic-concurrency token on PATCH.
# GET → mutate → PATCH on an asset (full-body round-trip; no fields stripped)
curl -sH "Authorization: Bearer $TRAKRF_API_KEY" \
"$BASE_URL/api/v1/assets/$ASSET_ID" \
| python3 -c "import json,sys; d=json.load(sys.stdin)['data']; d['name']='Quickstart test asset (renamed again)'; print(json.dumps(d))" \
| curl -X PATCH "$BASE_URL/api/v1/assets/$ASSET_ID" \
-H "Authorization: Bearer $TRAKRF_API_KEY" \
-H "Content-Type: application/merge-patch+json" \
-d @-
(If jq is installed, the equivalent pipeline stage is jq '.data | .name = "Quickstart test asset (renamed again)"'.)
For locations the same pattern holds — no explicit strip is needed for any read-only field; every value that matches the current resource state match-strips silently, and only a divergent value rejects. To re-parent a location, send either parent_id or parent_external_key; both are writable on PATCH /api/v1/locations/{location_id}, symmetric with CreateLocationRequest, and either form accepts null to detach the location to root. Supplying both with matching values is accepted; supplying both with differing values returns 400 validation_error / code: ambiguous_fields. To rename, use POST /api/v1/{resource}/{id}/rename — sending a different external_key on PATCH returns 400 validation_error with code: invalid_context (the field is settable, just not via this verb), so the rename surfaces in audit logs distinctly. Asset location cannot be set via POST or PATCH under any form — location_id / location_external_key are not part of the asset resource, and either field in a body is rejected 400 validation_error / code: read_only on presence; current location is derived from scan events and read from the scan-data endpoints. Mutating tags likewise uses the dedicated subresource — POST /…/tags and DELETE /…/tags/{tag_id} — and a different tags collection on PATCH returns 400 validation_error with code: invalid_context for the same reason. Differing values on the truly server-managed fields (id, created_at, updated_at, deleted_at) return code: read_only — see Errors → Validation errors for the code split.
Strict-typed codegen (Pydantic, Java with generated POJOs, Go with generated structs) already separates the read schema from UpdateAssetRequest / UpdateLocationRequest, so the read-only fields are excluded by construction in those clients. Hand-rolled clients and curl-based pipelines can send the full read shape back without scrubbing. Full per-field treatment: Resource identifiers → Read shape vs. write shape.
4. Alternative: Postman
Prefer a GUI? Postman (and Insomnia, Bruno, Hoppscotch) imports the OpenAPI spec from a URL and generates a request collection automatically:
- In Postman, File → Import → Link, paste
https://app.preview.trakrf.id/api/openapi.yaml. - Set the collection variables:
baseUrl→https://app.trakrf.id(orhttps://app.preview.trakrf.idfor preview accounts) — bare host, no/api/v1suffix; paths in the collection already include the version prefixbearerToken→ the JWT from step 2
- Collection auth is preconfigured as a Bearer token referencing
{{bearerToken}}.
Full detail: API clients.
5. Raw spec for codegen
If you'd rather generate a typed client, the OpenAPI spec is served from the platform in both formats:
-
https://app.preview.trakrf.id/api/openapi.json (JSON) -
https://app.preview.trakrf.id/api/openapi.yaml (YAML)
Feed either into openapi-generator-cli, NSwag, oapi-codegen, etc. to scaffold client code in your language. The spec is regenerated from the Go handlers on every platform release, so the generated client stays in sync with the running service.
The bare-form /openapi.yaml and /openapi.json at the app origin also resolve to the canonical spec (via 301 Moved Permanently to the /api/-prefixed paths above), matching the convention used by many SaaS APIs (Stripe, GitHub, and others). Either form is acceptable — pick whichever your codegen tooling prefers.
The spec declares its security scheme as BearerAuth (HTTP Bearer, JWT format), so class-emitting generators surface the credential as a Bearer access token — openapi-generator-cli's typescript-fetch target produces a Configuration with an accessToken field, and the Java/Python targets follow the same shape. The wire format remains Authorization: Bearer <jwt>; pass the API key minted in step 2 wherever the generated client expects an access token.
TrakRF spec round-trips end-to-end against openapi-typescript@7.x (TypeScript types feeding openapi-fetch), openapi-generator-cli python (Optional[...] for every nullable read field), and the typescript / Java targets from the same generator. If you reach for datamodel-codegen (Pydantic from OpenAPI), be aware that datamodel-codegen 0.57.0 emits nullable: true read-shape fields as non-Optional required types, so Pydantic raises ValidationError on every nullable field that actually comes back null (deleted_at on every resource, valid_to, and the location parent_id / parent_external_key pair). Either switch to the openapi-generator-cli python target or run datamodel-codegen with --use-annotated --use-union-operator plus a post-processing pass that wraps nullable fields in Optional[…]. The same note lives on the interactive reference's intro.
The spec declares ?external_key= as a repeatable query parameter for any-of semantics. Curl users pass it as ?external_key=A&external_key=B; typed clients map the repeatable shape to a list, so openapi-generator-cli's Python target expects external_key=['A', 'B'] — a bare string raises ValidationError. The same shape applies to any future repeatable query param the spec adds.
TypeScript with openapi-fetch
openapi-fetch is the type-safe, lightweight alternative most TypeScript integrators reach for. It's intentionally schema-agnostic at runtime, so it can't tell which paths expect application/merge-patch+json — every PATCH returns 415 unless you override Content-Type per call. Copy the mergePatchMiddleware helper (≈30 lines: a Middleware plus an optional createTrakrfClient wrapper) into your project, and a single client.use(mergePatchMiddleware) registration handles every PATCH site:
import createClient from "openapi-fetch";
import { mergePatchMiddleware } from "./merge-patch";
import type { paths } from "./schema";
const client = createClient<paths>({ baseUrl: "https://app.trakrf.id" });
client.use(mergePatchMiddleware);
await client.PATCH("/api/v1/assets/{asset_id}", {
params: { path: { asset_id: 42 } },
body: { name: "renamed" },
});
// → Content-Type: application/merge-patch+json on the wire, automatically
openapi-generator-cli's typescript-fetch target and Python's openapi-generator already handle merge-patch correctly out of the box — this helper exists only for the openapi-fetch integration path.
If you'd rather not copy the middleware file into your project, the same fix works inline per call by overriding headers (and optionally bodySerializer) on each PATCH request. Wrap it in a small helper so the override lives in one place:
import createClient from "openapi-fetch";
import type { paths } from "./schema";
const client = createClient<paths>({ baseUrl: "https://app.trakrf.id" });
// Per-call PATCH helper — sets Content-Type once, keeps body strongly-typed:
async function patchAsset(
id: number,
body: paths["/api/v1/assets/{asset_id}"]["patch"]["requestBody"]["content"]["application/merge-patch+json"],
) {
return client.PATCH("/api/v1/assets/{asset_id}", {
params: { path: { asset_id: id } },
headers: { "Content-Type": "application/merge-patch+json" },
body,
});
}
await patchAsset(42, { name: "renamed" });
// → Content-Type: application/merge-patch+json on the wire
openapi-fetch already calls JSON.stringify on the body by default — bodySerializer only needs an override if you want a different serializer; the Content-Type header is the only required change. Generalize the helper across /locations, tags, etc. by lifting the path into a parameter; the spec's PATCH operations all expect the same media type.
6. Beyond CRUD: the read-only tracking surface
The walkthrough above covers the master-data surface — assets:write and locations:write give you CRUD over what's in your fleet. The TrakRF API also exposes a read-only tracking surface derived from scan events ingested through reader integrations. Two endpoints, both gated on tracking:read:
GET /api/v1/assets/{asset_id}/history— Recent scan events for a single asset, in event order, each row carrying its observed location and the dwell duration at the previous location. ~90-day rolling window. Useful for per-asset timelines and dwell-time analysis. See Resource identifiers → "Scan event" is a domain concept, not an API resource for what's queryable and Date fields → Scan-event date fields for the response timestamps.GET /api/v1/reports/asset-locations— Current location of every asset in your organization, resolved from the most recent scan per asset. Useful for cross-fleet snapshots, nightly reconciliation, and "where is everything right now?" dashboards. See Resource identifiers →/reports/asset-locationsscopes to currently-effective assets for the default predicate andasset_last_seensemantics.
If your key was minted without tracking:read, the calls return 403 forbidden; scopes can't be edited after creation, so mint a new key to add the surface. The scopes array on GET /api/v1/orgs/me (covered in §2 above) confirms what your key carries.
Next steps
- Interactive reference — every endpoint, request/response shape
- Authentication — scopes, key lifecycle, rotation
- Pagination, filtering, sorting — conventions for list endpoints
- Resource identifiers — canonical
id, natural-keyexternal_key, and the?external_key=list filter - Errors — envelope, catalog, retry guidance
- Rate limits — budgets, headers,
Retry-After - Versioning — v1 stability commitment