Errors and scan outcomes
This is the lookup page for failure handling: the error body every endpoint returns, the codes worth branching on, and — most importantly — the canonical outcome tables for scanning. A “no match” is a result, not a transport failure, and it still arrives with an HTTP error status. Code that treats every non-2xx as a bug will report outages that never happened.
Per-endpoint schemas are in the API reference. The flows themselves are in Identifiers and the quickstart.
The error envelope
Section titled “The error envelope”Every failed request returns the same JSON object:
{ "code": "IDENTIFIER_NOT_FOUND", "message": "No match in the 3 searched teams", "status": 404, "detail": { "outcome": "no_match", "teamsSearched": 3, "scan": { "scanId": "…", "fingerprintId": "…", "dustId": null } }}| Field | Type | Meaning |
|---|---|---|
code | string | Stable machine-readable code. Branch on this. Never parse message. |
message | string | Human-readable text, localized per Dust-Ctx-Locale. Wording changes; codes do not. |
status | number | Mirrors the HTTP status. |
detail | object, optional | Per-error context: validation specifics, the scan receipt, outcome classification, conflict ids. |
status and the HTTP status always agree, so you can branch on either. Every response — success or failure — also carries an x-request-id header. Log it; support uses it to find your exact request.
Codes by class
Section titled “Codes by class”Request and authorization
Section titled “Request and authorization”| Code | Status | When |
|---|---|---|
INVALID_REQUEST | 400 | Malformed body, query, or header. Validation specifics in detail. |
INVALID_DATA | 400 | The request parsed but the values cannot be used (for example an identify payload the scan service refused to read). |
UNAUTHORIZED | 401 | Missing, expired, or invalid bearer token. |
FORBIDDEN | 403 | Authenticated, but this context may not do that. |
ATTRIBUTION_REQUIRED | 403 | The Service Account’s attribution policy is required and the write carried no Dust-Ctx-Declared-Actor. |
ORG_ID_REQUIRED / TEAM_ID_REQUIRED | 400 | A context header is missing on a scoped endpoint. |
NOT_FOUND / NO_DATA_FOUND | 404 | No such record is visible in this context. |
RATE_LIMITED | 429 | Back off and retry. |
THREAD_DATA_CONFLICT | 409 | Optimistic-concurrency conflict: your expectedUpdatedAt is stale. Re-read and reapply. |
COMPOSITE_TAG_CONFLICT | 409 | Label conflict — a DUST already on another Label or bound elsewhere, an occupied Reel position, or removing a Label’s last Identifier. detail.reason says which. |
UNKNOWN_ERROR / SERVICE_ERROR | 500 | Server-side failure. Retry with backoff; include the request id if it persists. |
Identifiers and scanning
Section titled “Identifiers and scanning”| Code | Status | Meaning |
|---|---|---|
IDENTIFIER_NOT_FOUND | 404 | A definitive miss: every searched partition answered, and nothing matched. |
IDENTIFIER_NOT_BOUND | 404 | The Identifier exists but is not bound to any Thread (only reachable through an identify by tagId). |
IDENTIFIER_ALREADY_BOUND | 409 | Bind refused — that Identifier (or its Label) is already on another Thread. detail.compositeTagId names the Label. |
IDENTIFIER_VERIFY_FAILED | 500 | Single-Identifier verify did not match, or the named Identifier is not bound to that Thread. |
SCAN_AMBIGUOUS_MATCH | 409 | Two or more distinct enrolled DUSTs matched and both are bound in the searched scope. Not retryable by the same capture. |
SCAN_SEARCH_INCOMPLETE | 503 | Some partitions answered “no match” but others could not be searched. This is not a miss — retry. |
SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS | 400 | The capture itself was rejected: too little usable detail. Rescan; do not retry the same image. |
SCAN_IDENTICAL_SCAN | 400 | The submitted image is the identical bytes of a previous capture. Take a fresh one. |
SCAN_EXTRACTION_FAILURE | 500 | Extraction failed on an image that was otherwise accepted. |
SCAN_ROUTING_UNAVAILABLE | 503 | The operation is not available for this organization (for example a module that is not enabled), or its route is down. |
SCAN_BACKEND_UNAVAILABLE | 503 | The scan backend is temporarily unavailable. The capture was fine — retry, do not rescan. |
Canonical identify outcomes
Section titled “Canonical identify outcomes”POST /api/v1/tags/identify has eight outcomes. Three are 200; the rest arrive with error statuses and are still answers. This table is the one source for both human and agent integrations — the same table appears in the dice-api-integration and dust-go-connect-integration skills.
| Outcome | HTTP | Body | What it means | What to do |
|---|---|---|---|---|
| Identified Thread | 200 | { type: "identified", identified: { tag, thread, … }, scan? } | Exactly one bound Identifier matched. | Open the Thread. scan.dustId is the resolved DUST. |
| Several candidates | 200 | { type: "matches", matches: [ … ], scan? } | More than one bound Identifier matched, or the match needs disambiguating. | Show the candidates and identify again by tagId (tagType: "ANY"). scan.dustId is null; each candidate carries its own. |
| Unbound Label | 200 | { type: "label", label: { label, tags }, scan? } | The scan resolved to a member of a Label in your Team’s inventory that is not bound to any Thread yet. | Offer to bind the Label. Not a miss. |
| No match | 404 | code: "IDENTIFIER_NOT_FOUND", detail.outcome: "no_match" | Every searched partition answered and nothing matched. detail.teamsSearched gives the scope. | Show “not found”. Do not report a service failure. Receipt on detail.scan. |
| Incomplete search | 503 | code: "SCAN_SEARCH_INCOMPLETE", detail.outcome: "search_incomplete" | Some partitions answered “no match”, others could not be reached. detail.teamsSearched, detail.teamsUnreachable, detail.orgsUnreachable. | Retry. Never render this as “not found” — the item may well be enrolled. |
| Ambiguous match | 409 | code: "SCAN_AMBIGUOUS_MATCH", detail.outcome: "ambiguous", detail.candidates, detail.boundCandidates, detail.attempts | Two or more bound DUSTs matched confidently. The platform searched the same capture twice before saying so. | Surface it with the scan.scanId and contact DUST Identity. A fresh capture of the same item will not resolve it. |
| Capture rejected | 400 | code: "SCAN_LOW_KEYPOINTS" / "SCAN_NO_KEYPOINTS" / "SCAN_IDENTICAL_SCAN", detail.outcome: "quality_reject" | The image could not be used. Quality rejection outranks every other partition outcome. | Ask the operator to rescan. The image is kept as a Rejected Scan; detail.scan.fingerprintId is null. |
| Identifier not bound | 404 | code: "IDENTIFIER_NOT_BOUND" | Only from an identify by tagId (tagType: "ANY"): the Identifier exists but has no Thread. | Offer to bind it. |
detail.outcome is the classification the server used and is stable: no_match, search_incomplete, ambiguous, quality_reject. Branch on code first and read detail.outcome when you need the finer distinction.
Branching on an identify result
Section titled “Branching on an identify result”// Runs on your SERVER (it holds the bearer token).const response = await fetch(`${apidUrl}/api/v1/tags/identify`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Dust-Ctx-Org-Id": organizationId }, body: form,});const body = await response.json();
if (response.ok) { switch (body.type) { case "identified": return { kind: "thread", thread: body.identified.thread }; case "matches": return { kind: "candidates", candidates: body.matches }; case "label": return { kind: "unbound-label", label: body.label }; default: throw new Error(`Unknown identify result type: ${body.type}`); }}
switch (body.code) { case "IDENTIFIER_NOT_FOUND": // An answer, not an outage. return { kind: "no-match", scanId: body.detail?.scan?.scanId ?? null }; case "SCAN_SEARCH_INCOMPLETE": case "SCAN_BACKEND_UNAVAILABLE": return { kind: "retry", scanId: body.detail?.scan?.scanId ?? null }; case "SCAN_LOW_KEYPOINTS": case "SCAN_NO_KEYPOINTS": case "SCAN_IDENTICAL_SCAN": return { kind: "rescan", scanId: body.detail?.scan?.scanId ?? null }; case "SCAN_AMBIGUOUS_MATCH": return { kind: "ambiguous", scanId: body.detail?.scan?.scanId ?? null }; default: throw new Error(`${body.code}: ${body.message}`);}Verify outcomes
Section titled “Verify outcomes”POST /api/v1/tags/verify behaves differently depending on how many candidate Identifiers you send in tags, because one candidate is a yes/no question and several is a search:
tags length | Match | No match |
|---|---|---|
| Exactly one | 200 with { tag, scan? } | IDENTIFIER_VERIFY_FAILED (500), receipt on detail.scan |
| Two or more | 200 with { success: true, verifiedTag, attemptedCount, failedCount, scan? } | 200 with { success: false, attemptedCount, failedCount, error, scan? } |
So a bulk verify that fails is a successful HTTP call with success: false. Always read success when you send more than one candidate, and never infer authenticity from response.ok alone.
tags is required on every verify. It is an array of objects, not of ids:
[{ "tagId": "8f2b…", "tagType": "DUST" }]Scan receipts survive failure
Section titled “Scan receipts survive failure”Every operation that submits an image — extract, bind, identify, verify, tamper analysis — returns a scan receipt naming what was stored:
{ "scanId": "…", "fingerprintId": "…", "dustId": "…" }- On success it rides at the top level as
scan. - On a stored-but-negative outcome it rides at
detail.scan— a verify mismatch, an identify no-match, a bind refused as a duplicate, and a quality rejection (wherefingerprintIdisnull, because nothing usable was extracted).
Capture scanId on both paths. It is the stable identity of the capture across algorithm migrations, and it is what support needs to look at the image behind a disputed result. Only an image the platform could not decode at all stores nothing, and then there is no receipt.
const receipt = response.ok ? body.scan : body.detail?.scan;if (receipt) await recordScan(receipt.scanId, receipt.fingerprintId, receipt.dustId);dustId is what the platform returned to you, never a raw internal match: it is null on a mismatch, a no-match, an extract, a tamper analysis, and on an identify that returned several candidates.
Token expiry and refresh
Section titled “Token expiry and refresh”Bearer tokens are short-lived and there is no refresh token — you re-exchange the credential. An expired token is an ordinary 401 UNAUTHORIZED, indistinguishable from a revoked one, so handle both the same way:
- Refresh proactively.
GET /api/auth/tokenreturnsexpiresIn(seconds) andexpiresAt(ISO 8601) whenever the token carries an expiry claim. Re-exchange with a margin (60 seconds is comfortable); never hardcode a lifetime. - Retry once on
401. Clock skew and mid-lifetime revocation both land here. One refresh-and-retry is the right amount; a loop is not. - Mint per request from a cache, not once at process start, so a job that outlives one token does not fail halfway.
The worked implementation is in Authentication → Token expiry and refresh.
Retry guidance
Section titled “Retry guidance”| Situation | Retry the same request? | Notes |
|---|---|---|
401 UNAUTHORIZED | Yes, once, after re-exchanging the credential | More than once means the credential itself is wrong. |
429 RATE_LIMITED | Yes, with backoff | |
503 SCAN_SEARCH_INCOMPLETE / SCAN_BACKEND_UNAVAILABLE | Yes — the capture is fine | Do not make the operator rescan. |
503 SCAN_ROUTING_UNAVAILABLE | No | The operation is unavailable for this organization; ask DUST Identity. |
400 SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS / SCAN_IDENTICAL_SCAN | No — rescan instead | The same bytes will be rejected again. |
404 IDENTIFIER_NOT_FOUND | No | It is an answer. |
409 SCAN_AMBIGUOUS_MATCH | No | Already retried server-side; detail.attempts says so. |
409 THREAD_DATA_CONFLICT | Re-read, reapply, then write | Do not blind-retry — you would overwrite someone. |
5xx UNKNOWN_ERROR | Yes, with backoff, for idempotent reads | For writes, check whether the write landed before retrying. |
See also
Section titled “See also”- Request conventions — headers, pagination, localization.
- Identifiers — the scanning operations these outcomes come from.
- Authentication and API keys — credentials and token lifetime.
- API reference — per-endpoint response schemas.