Skip to content

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.

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 } }
}
FieldTypeMeaning
codestringStable machine-readable code. Branch on this. Never parse message.
messagestringHuman-readable text, localized per Dust-Ctx-Locale. Wording changes; codes do not.
statusnumberMirrors the HTTP status.
detailobject, optionalPer-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.

CodeStatusWhen
INVALID_REQUEST400Malformed body, query, or header. Validation specifics in detail.
INVALID_DATA400The request parsed but the values cannot be used (for example an identify payload the scan service refused to read).
UNAUTHORIZED401Missing, expired, or invalid bearer token.
FORBIDDEN403Authenticated, but this context may not do that.
ATTRIBUTION_REQUIRED403The Service Account’s attribution policy is required and the write carried no Dust-Ctx-Declared-Actor.
ORG_ID_REQUIRED / TEAM_ID_REQUIRED400A context header is missing on a scoped endpoint.
NOT_FOUND / NO_DATA_FOUND404No such record is visible in this context.
RATE_LIMITED429Back off and retry.
THREAD_DATA_CONFLICT409Optimistic-concurrency conflict: your expectedUpdatedAt is stale. Re-read and reapply.
COMPOSITE_TAG_CONFLICT409Label 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_ERROR500Server-side failure. Retry with backoff; include the request id if it persists.
CodeStatusMeaning
IDENTIFIER_NOT_FOUND404A definitive miss: every searched partition answered, and nothing matched.
IDENTIFIER_NOT_BOUND404The Identifier exists but is not bound to any Thread (only reachable through an identify by tagId).
IDENTIFIER_ALREADY_BOUND409Bind refused — that Identifier (or its Label) is already on another Thread. detail.compositeTagId names the Label.
IDENTIFIER_VERIFY_FAILED500Single-Identifier verify did not match, or the named Identifier is not bound to that Thread.
SCAN_AMBIGUOUS_MATCH409Two or more distinct enrolled DUSTs matched and both are bound in the searched scope. Not retryable by the same capture.
SCAN_SEARCH_INCOMPLETE503Some partitions answered “no match” but others could not be searched. This is not a miss — retry.
SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS400The capture itself was rejected: too little usable detail. Rescan; do not retry the same image.
SCAN_IDENTICAL_SCAN400The submitted image is the identical bytes of a previous capture. Take a fresh one.
SCAN_EXTRACTION_FAILURE500Extraction failed on an image that was otherwise accepted.
SCAN_ROUTING_UNAVAILABLE503The operation is not available for this organization (for example a module that is not enabled), or its route is down.
SCAN_BACKEND_UNAVAILABLE503The scan backend is temporarily unavailable. The capture was fine — retry, do not rescan.

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.

OutcomeHTTPBodyWhat it meansWhat to do
Identified Thread200{ type: "identified", identified: { tag, thread, … }, scan? }Exactly one bound Identifier matched.Open the Thread. scan.dustId is the resolved DUST.
Several candidates200{ 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 Label200{ 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 match404code: "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 search503code: "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 match409code: "SCAN_AMBIGUOUS_MATCH", detail.outcome: "ambiguous", detail.candidates, detail.boundCandidates, detail.attemptsTwo 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 rejected400code: "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 bound404code: "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.

// 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}`);
}

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 lengthMatchNo match
Exactly one200 with { tag, scan? }IDENTIFIER_VERIFY_FAILED (500), receipt on detail.scan
Two or more200 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" }]

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 (where fingerprintId is null, 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.

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:

  1. Refresh proactively. GET /api/auth/token returns expiresIn (seconds) and expiresAt (ISO 8601) whenever the token carries an expiry claim. Re-exchange with a margin (60 seconds is comfortable); never hardcode a lifetime.
  2. 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.
  3. 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.

SituationRetry the same request?Notes
401 UNAUTHORIZEDYes, once, after re-exchanging the credentialMore than once means the credential itself is wrong.
429 RATE_LIMITEDYes, with backoff
503 SCAN_SEARCH_INCOMPLETE / SCAN_BACKEND_UNAVAILABLEYes — the capture is fineDo not make the operator rescan.
503 SCAN_ROUTING_UNAVAILABLENoThe operation is unavailable for this organization; ask DUST Identity.
400 SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS / SCAN_IDENTICAL_SCANNo — rescan insteadThe same bytes will be rejected again.
404 IDENTIFIER_NOT_FOUNDNoIt is an answer.
409 SCAN_AMBIGUOUS_MATCHNoAlready retried server-side; detail.attempts says so.
409 THREAD_DATA_CONFLICTRe-read, reapply, then writeDo not blind-retry — you would overwrite someone.
5xx UNKNOWN_ERRORYes, with backoff, for idempotent readsFor writes, check whether the write landed before retrying.