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 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", "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 natural-key reference fields external_key, location_id, location_external_key, plus parent_external_key on locations) 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.
When you cache a GET response and PATCH later, updated_at is the field that may drift — it advances every time PATCH actually changes a settable column. A verbatim no-op body (every settable field at its current value) doesn't drift it; the storage IS DISTINCT FROM short-circuit makes the no-op case fully wire-idempotent. A real intervening write by another caller is the case to plan for: re-GET immediately before PATCH to refresh updated_at, or strip it from the cached body. This is optimistic concurrency working as designed — the accept-if-matches rule on updated_at is how the API prevents lost updates from concurrent edits.
# 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 echoes silently. To re-parent a location, mutate parent_id (the surrogate is the writable canonical for the parent relationship). To rename, use POST /api/v1/{resource}/{id}/rename — sending a different external_key on PATCH returns 400 read_only, so the rename surfaces in audit logs distinctly. Asset location specifically cannot be set via PATCH under any form; current location is derived from scan events. Mutating tags likewise uses the dedicated subresource — POST /…/tags and DELETE /…/tags/{tag_id} — and a different tags collection on PATCH returns 400 read_only.
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 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 [email protected] (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 [email protected] emits nullable: true read-shape fields as non-Optional required types, so Pydantic raises ValidationError on every nullable field that actually comes back null (asset.location_id, asset.location_external_key, every deleted_at, plus the valid_to / parent_id family). 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.
Next steps
- Interactive reference — every endpoint, request/response shape, try-it-now widget
- 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