API quickstart
By the end of this page you will have exchanged an API key for a bearer token, discovered the organization and Team your credential can act in, created a Thread, and read it back with its event history.
Both language paths below are complete and independent: everything is defined before it is used, and neither borrows a step from the other. Pick one tab and stay in it.
Before you start
Section titled “Before you start”You need:
- A Service Account API key, issued by an organization admin. Personal API keys do not exist — see Authentication and API keys if you do not have one yet.
- The Service Account must be a member of the Team you write into. Creating Threads requires current membership in the selected Team; an organization-level credential that belongs to no Team can read
/api/v1/mebut cannot create records. Ask your admin to add it to a Team if step 3 returns403 FORBIDDEN. - curl path:
curlandjq(the examples parse JSON with it; if you would rather not installjq, copy the values out of the responses by hand). - TypeScript path: a runtime that runs TypeScript directly and has a global
fetch— Node.js 22.18 or newer, Bun, or Deno. On Node.js 18 or 20, run the file with a loader such astsxinstead. No packages to install: the examples use plainfetchonly. A typed client is available separately, see TypeScript client.
All requests go to https://apid.dustid.io; see Environments for the other service URLs.
-
Set up your environment
Section titled “Set up your environment”Terminal window export APID_URL="https://apid.dustid.io"export DUST_API_KEY="your-service-account-key" # read this from your secrets managerSave the file below as
quickstart.tsand run it withnode quickstart.ts,bun quickstart.ts, ordeno run --allow-net --allow-env quickstart.ts. Each step adds to the same file.quickstart.ts // Makes the file an ES module, which is what lets the top-level `await`s// below run. (A `.mts` extension, or "type": "module" in package.json,// does the same job.)export {};const apidUrl = "https://apid.dustid.io";const apiKey = process.env.DUST_API_KEY;if (!apiKey) throw new Error("Set DUST_API_KEY in the environment."); -
Exchange the API key for a bearer token
Section titled “Exchange the API key for a bearer token”API keys are never sent to
/api/v1/*endpoints. Exchange the key once atGET /api/auth/token, passing it in thex-api-keyheader, and send the resulting JWT asAuthorization: Bearer <token>on every subsequent call.Terminal window curl -fsS "$APID_URL/api/auth/token" -H "x-api-key: $DUST_API_KEY"{ "token": "eyJhbGciOi...", "expiresIn": 900, "expiresAt": "2026-09-20T22:40:00.000Z" }Terminal window export DUST_TOKEN="$(curl -fsS "$APID_URL/api/auth/token" -H "x-api-key: $DUST_API_KEY" | jq -r '.token')"quickstart.ts type TokenResponse = { token: string; expiresIn?: number; expiresAt?: string };async function exchangeToken(): Promise<TokenResponse> {const response = await fetch(`${apidUrl}/api/auth/token`, {headers: { "x-api-key": apiKey! },});if (!response.ok) {throw new Error(`Token exchange failed: ${response.status} ${await response.text()}`);}return (await response.json()) as TokenResponse;}const { token, expiresIn, expiresAt } = await exchangeToken();console.log(`Token valid for ${expiresIn ?? "unknown"}s (until ${expiresAt ?? "unknown"})`);The response carries
token, and — whenever the JWT itself has an expiry claim —expiresIn(seconds remaining) andexpiresAt(ISO 8601). Read the lifetime from the response rather than hardcoding one: tokens are currently short-lived (about 15 minutes) and there is no refresh token, so a long-running job must re-exchange mid-run. The full lifetime contract, a caching implementation, and the refresh-once-on-401pattern are in Authentication → Token expiry and refresh. -
Discover your organization
Section titled “Discover your organization”GET /api/v1/meis one of the few endpoints that needs no context headers. It describes the credential itself: the principal, the organizations it belongs to, and which one is active.Terminal window curl -fsS "$APID_URL/api/v1/me" -H "Authorization: Bearer $DUST_TOKEN"{"userId": "6a1f…","email": "sap-connector@example.com","name": "SAP Connector","activeOrganizationId": "b2c7…","organizations": [{ "id": "b2c7…", "name": "Anchor Electronics", "slug": "anchor-electronics", "roles": ["member"] }]}Terminal window # Prefer the active organization; fall back to the first membership.export DUST_ORG_ID="$(curl -fsS "$APID_URL/api/v1/me" -H "Authorization: Bearer $DUST_TOKEN" \| jq -er '.activeOrganizationId // .organizations[0].id')"echo "Organization: $DUST_ORG_ID"quickstart.ts type Organization = { id: string; name: string; slug: string; roles: string[] };type MeResponse = {userId: string;email: string;activeOrganizationId?: string | null;organizations: Organization[];};const auth = { Authorization: `Bearer ${token}` };const meResponse = await fetch(`${apidUrl}/api/v1/me`, { headers: auth });if (!meResponse.ok) {throw new Error(`/me failed: ${meResponse.status} ${await meResponse.text()}`);}const me = (await meResponse.json()) as MeResponse;const organizationId =me.activeOrganizationId ?? me.organizations[0]?.id;if (!organizationId) {throw new Error("This credential belongs to no organization — ask your admin.");}console.log(`Organization: ${organizationId}`); -
Choose a Team (optional)
Section titled “Choose a Team (optional)”Records belong to a Team inside the organization. You have two supported options:
- Do nothing. Omit
Dust-Ctx-Team-Idand the API acts in the organization’s root Team. That is the whole of step 4 for a single-Team organization, and the examples in step 5 take this path. - Name a Team.
GET /api/v1/teamslists the Teams your credential is a member of, as{ "teams": [ … ], "total": n }, each withteamId,orgId, andname. Send the one you want asDust-Ctx-Team-Id.
Terminal window curl -fsS "$APID_URL/api/v1/teams?pageSize=50" \-H "Authorization: Bearer $DUST_TOKEN" \-H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \| jq '.teams[] | { teamId, name }'{ "teamId": "b2c7…", "name": "Anchor Electronics" }{ "teamId": "4e90…", "name": "Line 3 Receiving" }Terminal window # Optional. Leave DUST_TEAM_ID unset to use the organization's root Team.export DUST_TEAM_ID="4e90…"Every request below passes
-H "Dust-Ctx-Team-Id: ${DUST_TEAM_ID:-}". An empty value is treated exactly like an absent header — the organization’s root Team — so the same script runs whether or not you set the variable.quickstart.ts type Team = { teamId: string; orgId: string; name: string | null };const teamsResponse = await fetch(`${apidUrl}/api/v1/teams?pageSize=50`, {headers: { ...auth, "Dust-Ctx-Org-Id": organizationId },});if (!teamsResponse.ok) {throw new Error(`/teams failed: ${teamsResponse.status} ${await teamsResponse.text()}`);}const { teams } = (await teamsResponse.json()) as { teams: Team[]; total: number };for (const team of teams) console.log(`${team.teamId} ${team.name ?? "(unnamed)"}`);// Optional. Leave DUST_TEAM_ID unset to act in the organization's root Team.const teamId = process.env.DUST_TEAM_ID;// Context headers for every call from here on. The Team header is present// only when a Team was chosen — an undefined value must not be sent.const context: Record<string, string> = {...auth,"Dust-Ctx-Org-Id": organizationId,...(teamId ? { "Dust-Ctx-Team-Id": teamId } : {}),}; - Do nothing. Omit
-
Create a Thread
Section titled “Create a Thread”A Thread is the record for one asset or item.
POST /api/v1/threadswithtype: "single"creates one;thread.nameis the only required field, and the optionaldataarray carries typed fields.Terminal window curl -fsS "$APID_URL/api/v1/threads" \-H "Authorization: Bearer $DUST_TOKEN" \-H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \-H "Dust-Ctx-Team-Id: ${DUST_TEAM_ID:-}" \-H "Content-Type: application/json" \-d '{"type": "single","thread": {"name": "Tire SZ3J-11-ZJ17","description": "Production asset"},"data": [{ "name": "Serial Number", "type": "text", "value": { "text": "SZ3J-11-ZJ17" } },{ "name": "Max PSI", "type": "number", "value": { "number": 51 } }]}' | tee /tmp/created.json | jq '.created[0] | { threadId, name }'{ "threadId": "0f13c0de-2f1a-4a2e-9f60-6d2f7b9f0a11", "name": "Tire SZ3J-11-ZJ17" }Terminal window export THREAD_ID="$(jq -r '.created[0].threadId' /tmp/created.json)"quickstart.ts type ThreadRecord = { threadId: string; name: string | null };const createResponse = await fetch(`${apidUrl}/api/v1/threads`, {method: "POST",headers: { ...context, "Content-Type": "application/json" },body: JSON.stringify({type: "single",thread: { name: "Tire SZ3J-11-ZJ17", description: "Production asset" },data: [{ name: "Serial Number", type: "text", value: { text: "SZ3J-11-ZJ17" } },{ name: "Max PSI", type: "number", value: { number: 51 } },],}),});if (!createResponse.ok) {const error = await createResponse.json();throw new Error(`create failed: ${error.code} — ${error.message}`);}const { created } = (await createResponse.json()) as { created: ThreadRecord[] };const threadId = created[0]?.threadId;if (!threadId) throw new Error("The server created no Thread.");console.log(`Created ${threadId}`);The status is
201 Createdand the body is{ "created": [ … ], "uploadResponses": [] }— a batch shape, because the same endpoint creates many Threads at once withtype: "list"ortype: "raw". Each entry increatedis a full Thread record including its generatedthreadId.Field entries need
typeandvalue, and the shape ofvaluefollows the type:{ "text": "…" }fortext,{ "number": 51 }fornumber.nameis the field’s label. The full list of field types is in the Threads guide. -
Read it back
Section titled “Read it back”GET /api/v1/threads/{thread_id}returns the Thread plus its event history — every write is recorded, so the audit trail starts at creation.Terminal window curl -fsS "$APID_URL/api/v1/threads/$THREAD_ID" \-H "Authorization: Bearer $DUST_TOKEN" \-H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \-H "Dust-Ctx-Team-Id: ${DUST_TEAM_ID:-}" \| jq '{ name: .thread.name, fields: [.thread.data[]?.name], events: (.events | length) }'{ "name": "Tire SZ3J-11-ZJ17", "fields": ["Serial Number", "Max PSI"], "events": 1 }quickstart.ts const getResponse = await fetch(`${apidUrl}/api/v1/threads/${threadId}`, {headers: context,});if (!getResponse.ok) {const error = await getResponse.json();throw new Error(`read failed: ${error.code} — ${error.message}`);}const record = (await getResponse.json()) as {thread: { name: string | null };events: unknown[];};console.log(record.thread.name); // "Tire SZ3J-11-ZJ17"console.log(record.events.length); // at least 1 — creation is an eventThe response shape is
{ "thread": { … }, "events": [ … ] }. The exact event count is not a contract — expect at least one.
If it did not work
Section titled “If it did not work”| What you saw | What it means |
|---|---|
401 UNAUTHORIZED on the exchange | The API key is wrong, revoked, or not a Service Account key. Personal keys do not authenticate. |
401 UNAUTHORIZED on a /api/v1/* call | The bearer token expired (they are short-lived). Re-exchange and retry once. |
400 ORG_ID_REQUIRED | You omitted Dust-Ctx-Org-Id on an org-scoped endpoint. |
400 INVALID_REQUEST naming a header | A context header was not a UUID. Headers are validated before the endpoint runs. |
403 FORBIDDEN on create | The Service Account is not a member of the selected Team. Ask your admin to add it. |
404 on the read-back | Usually the wrong context, not a missing record — a Thread is only visible in the organization and Team that own or were shared it. |
Every error body is { code, message, status, detail? }, and every response carries an x-request-id header worth logging. The complete code list, the scanning outcome tables, and retry guidance are in Errors and scan outcomes.
Where to go next
Section titled “Where to go next”- Request conventions — context headers, pagination, localization.
- Errors and scan outcomes — the failure contract in full.
- Threads — field types, updates, archiving, listing and search.
- Identifiers — bind and verify physical identifiers against Threads (the
/api/v1/tags/*endpoints). - Files — attach evidence files to Threads.
- Teams and sharing — cross-Team access.
- TypeScript client — a typed alternative to raw
fetch. - Full API reference — every endpoint, generated from the OpenAPI spec. The API server also self-hosts an interactive reference at
https://apid.dustid.io/api/docsand the raw spec at/api/openapi.json.