Identifiers API guide
An identifier connects a physical marking to a Thread: a DUST tag, QR code, barcode, Data Matrix symbol, NFC chip, or a printed text code. Once bound, a scan in the field resolves to the digital record. The API namespace is /api/v1/tags — legacy naming that survives in paths and schemas; these docs say identifier in prose.
Full request/response schemas: API reference. Every outcome each operation can produce — including the ones that arrive as HTTP errors — is tabulated once in Errors and scan outcomes.
Operations at a glance
Section titled “Operations at a glance”| Operation | Method & path | Semantics |
|---|---|---|
| Extract | POST /api/v1/tags/extract | Parse a DUST capture into a canonical fingerprint, without binding |
| Bind | POST /api/v1/tags/bind | Associate an identifier with a Thread |
| Identify | POST /api/v1/tags/identify | Search: which Thread matches this scan? |
| Verify | POST /api/v1/tags/verify | Compare a scan against a specific Thread’s identifiers |
| Unbind | POST /api/v1/tags/unbind | Detach an identifier from its Thread |
| Set text | POST /api/v1/tags/text | Rename / re-describe a bound identifier |
| Update | POST /api/v1/tags/update | Lifecycle: privacy, archive/restore (value and type are immutable) |
Identify vs. verify: identify answers “what is this?” — it searches your visible Threads (scoped by searchTeamIds) and returns the match, if any. Verify answers “is this the item it claims to be?” — you name a threadId and the candidate identifiers bound to it, and the API confirms or denies. Use verify for authentication decisions; identify for lookup.
Two payload families
Section titled “Two payload families”Scan endpoints accept multipart/form-data, and the shape of data depends on the identifier type:
tagType | data | Where it comes from |
|---|---|---|
DUST | An image — a binary file part or a base64 data URL (data:image/jpeg;base64,…) | A DUST optical capture from a scanner |
QR, BAR_CODE, DATA_MATRIX, NFC | The decoded string contents (or NFC hex ID) | Any symbol scanner |
TEXT | The printed human-readable code, as a person reads it | Keyboard entry, or a Label enrollment |
A DUST capture is a photograph of the tag, not a decoded value — the server extracts the fingerprint. Captures come from DUST scanning hardware: see Integrate with DUST Go for mobile capture and the React Scanner for a drop-in web component that handles all modes.
In multipart bodies, structured fields (options, tags, searchTeamIds) are passed as JSON strings.
Text identifiers
Section titled “Text identifiers”TEXT is the human-readable code printed on an item or Label — a serial such as AB00017. There is no symbol to decode, so the value is typed in (or comes from a Label’s enrollment record) and is stored exactly as entered. Because a person is the reader, TEXT is the one type the platform matches case-insensitively: identifying or verifying with ab00017 finds a bound AB00017. Every other type is matched byte-for-byte.
Like QR, barcode, Data Matrix, and NFC values, a text code is copyable and carries no uniqueness of its own — the same code may legitimately appear on several Threads, or on every Label of a Reel. Nothing rejects a repeated value; a Reel simply reports the repeats as a warning.
Extract a DUST capture
Section titled “Extract a DUST capture”Extract parses a capture into a canonical fingerprint and returns its quality — useful for checking a capture before enrolling, or for staging a bind:
curl -fsS "$APID_URL/api/v1/tags/extract" \ -H "Authorization: Bearer $DUST_TOKEN" \ -H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \ -F "data=@scan.jpeg" \ -F 'options={"enrollmentSessionId":"3d5e…"}'The response is { id, qualityScore, annotatedImage?, forensics?, scan? } — id is a fingerprint ID you can later bind without re-uploading the image (below). options also carries capture metadata (device, optics, geolocation) that the platform stores with the scan.
The scan receipt
Section titled “The scan receipt”Every operation that submits an image — extract, bind, identify, verify, and tamper analysis — returns a scan object naming what was stored:
{ "scanId": "…", "fingerprintId": "…", "dustId": "…" }scanIdis always present once the image was stored. It is the stable identity of the capture, and the value to keep if you record scan operations on your side.fingerprintIdis present when extraction succeeded (nullotherwise).dustIdis present when the operation returned a DUST to you: the identifier a bind created, a verify confirmed, or an identify resolved. It isnullon a mismatch, a no-match, an extract, a tamper analysis (the identifier there is one you supplied, not one the image resolved to), and on an identify that returned several candidates (each candidate carries its own identifier).
A verify mismatch and an identify no-match keep their existing error status and code, and carry the same receipt under detail.scan — the scan was stored even though the outcome was negative. So does a capture rejected for quality — too few or no usable keypoints — whatever error code the operation reports for it (/tags/extract answers SCAN_EXTRACTION_FAILURE; identify surfaces SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS; verify answers its usual IDENTIFIER_VERIFY_FAILED): the image is kept and its receipt has fingerprintId: null, because nothing usable was extracted from it. Only an image the platform could not decode at all stores nothing and has no receipt.
Bind an identifier to a Thread
Section titled “Bind an identifier to a Thread”POST /api/v1/tags/bind accepts three shapes, distinguished by tagType and payload:
curl -fsS "$APID_URL/api/v1/tags/bind" \ -H "Authorization: Bearer $DUST_TOKEN" \ -H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \ -F "threadId=$THREAD_ID" \ -F "tagType=DUST" \ -F "tagDescription=Inbound receiving scan" \ -F "data=@scan.jpeg" \ -F 'options={"enrollmentSessionId":"3d5e…"}'# QR, BAR_CODE, DATA_MATRIX, NFC: the decoded contentscurl -fsS "$APID_URL/api/v1/tags/bind" \ -H "Authorization: Bearer $DUST_TOKEN" \ -H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \ -F "threadId=$THREAD_ID" \ -F "tagType=QR" \ -F "data=https://example.com/item/SZ3J-11-ZJ17"# TEXT: the printed code as a person reads itcurl -fsS "$APID_URL/api/v1/tags/bind" \ -H "Authorization: Bearer $DUST_TOKEN" \ -H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \ -F "threadId=$THREAD_ID" \ -F "tagType=TEXT" \ -F "data=AB00017"# Reuse a fingerprint from a prior /extract — no image re-uploadcurl -fsS "$APID_URL/api/v1/tags/bind" \ -H "Authorization: Bearer $DUST_TOKEN" \ -H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \ -F "threadId=$THREAD_ID" \ -F "tagType=DUST" \ -F "fingerprintId=$FINGERPRINT_ID"options.enrollmentSessionId is optional on a DUST bind. Supply a client-generated UUID — the same one across a run — when several captures belong together, such as an enrollment station working through a batch or a multi-angle capture of one item; the platform then groups those scans under that session. Omit it entirely for a one-off bind. DUST image binds can also return the annotated capture with options.returnAnnotatedImage: true.
Binding a Label
Section titled “Binding a Label”If the scanned identifier is a member of a Label owned by the Thread’s Team (see Labels), bind does not create a loose identifier. It binds the whole Label: every active member identifier attaches to the Thread in one operation, and the response carries label (the Label, its Reel and position) plus boundTags (every member that was bound) alongside the usual tag, which is the member you scanned. Pass activateLabel: true to also make the Label’s DUST identifiers identifiable as part of the bind; this is optional. A Label already bound to a different Thread returns IDENTIFIER_ALREADY_BOUND with detail.compositeTagId.
Identify a Thread from a scan
Section titled “Identify a Thread from a scan”curl -fsS "$APID_URL/api/v1/tags/identify" \ -H "Authorization: Bearer $DUST_TOKEN" \ -H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \ -F "tagType=DUST" \ -F "data=@scan.jpeg" \ -F 'searchTeamIds=["'"$TEAM_ID"'"]'const result = await client.tags.identify({ tagType: "DUST", data: scanBlob, searchTeamIds: [teamId],});Identify also takes value payloads (tagType of QR/BAR_CODE/DATA_MATRIX/NFC with the decoded data, or TEXT with the printed code, matched case-insensitively) or a bare identifier ID (tagType: "ANY" with tagId).
What identify can return
Section titled “What identify can return”A hit returns 200 with the matched identifier and its Thread. A miss is an error status, not a 200 with an empty result: a definitive no-match is 404 IDENTIFIER_NOT_FOUND, and a search that could not be completed is 503 SCAN_SEARCH_INCOMPLETE — a different situation that must not be shown to an operator as “not found”. Both carry the scan receipt on detail.scan.
The canonical table of all eight outcomes — identified Thread, several candidates, unbound Label, no match, incomplete search, ambiguous match, capture rejected, identifier not bound — with each one’s status, code and correct client response, is in Errors and scan outcomes → Canonical identify outcomes. Branch on code, and read detail.outcome (no_match, search_incomplete, ambiguous, quality_reject) when you need the finer distinction.
Every returned identifier that belongs to a Label carries tag.label (its Label, Reel and position). When the scan matches a member of an unbound Label in the active Team’s inventory, the result is { type: "label", label: { label, tags } }: no Thread yet, but the Label and its member identifiers, so a client can offer to bind it (see Labels).
Choosing the search scope
Section titled “Choosing the search scope”searchTeamIds is a JSON array of Team UUIDs (a JSON string in multipart bodies). Omit it and identify searches exactly one Team: the one named by Dust-Ctx-Team-Id, which itself defaults to the organization’s root Team.
The ids are not arbitrary. Same-organization Teams you belong to are always in scope; a partner organization’s Team is reachable only through an active Connection that lets its data flow to you. Anything else is silently dropped from the scope rather than failing the request, so a scope that looks wide can search narrowly. Discover the valid ids rather than hardcoding them:
GET /api/v1/teams— the Teams in your organization that your credential belongs to ({ teams: [{ teamId, orgId, name, … }], total }).GET /api/v1/teams/connected— the partner Teams you may search, as Connection records naming the two linked Teams.
Verify a scan against a Thread
Section titled “Verify a scan against a Thread”Verify is the authentication primitive: given a fresh scan, a threadId, and the candidate tags already bound to that Thread, it succeeds if any candidate matches.
tags is required, and it is an array of objects — each { "tagId": "…", "tagType": "…" } — not an array of id strings. In a multipart body it is sent as a JSON string:
curl -fsS "$APID_URL/api/v1/tags/verify" \ -H "Authorization: Bearer $DUST_TOKEN" \ -H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \ -F "threadId=$THREAD_ID" \ -F "tagType=DUST" \ -F "data=@scan.jpeg" \ -F 'tags=[{"tagId":"'"$TAG_ID"'","tagType":"DUST"}]'const form = new FormData();form.set("threadId", threadId);form.set("tagType", "DUST");form.set("data", scanBlob);form.set("tags", JSON.stringify([{ tagId, tagType: "DUST" }]));Where the candidate ids come from
Section titled “Where the candidate ids come from”tagId values are the identifiers already bound to the Thread you are checking. Read them from the Thread: GET /api/v1/threads/{thread_id} returns them in thread.tags, each with its tagId and tagType. A typical verify therefore fetches the Thread, filters its identifiers to the type you just captured, and sends those as the candidate list:
const record = await getThread(threadId); // GET /api/v1/threads/{thread_id}const candidates = (record.thread.tags ?? []) .filter((tag) => tag.tagType === "DUST") .map((tag) => ({ tagId: tag.tagId, tagType: tag.tagType }));Sending an identifier that is not bound to that Thread fails the verify rather than matching something else.
Reading the result
Section titled “Reading the result”The number of candidates changes the response shape, which is the single most common integration mistake here:
tags length | Match | No match |
|---|---|---|
| Exactly one | 200 with { tag, scan? } | IDENTIFIER_VERIFY_FAILED (HTTP 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 multi-candidate verify that fails is a successful HTTP call carrying success: false. Never treat response.ok as proof of authenticity — read success whenever you send more than one candidate. See Errors and scan outcomes → Verify outcomes.
Manage bound identifiers
Section titled “Manage bound identifiers”These are plain JSON endpoints; all of them require both the tagId and the threadId the identifier is bound to:
POST /api/v1/tags/text— setnameand/ordescription.POST /api/v1/tags/update— setname,description,isPrivate, andarchivedAt(an ISO timestamp archives the identifier;nullrestores it). The identifier’s value and type are immutable — rebind instead.POST /api/v1/tags/unbind— detach the identifier from the Thread.
Tamper Analysis
Section titled “Tamper Analysis”Under /api/v1/tamper, a Tamper Analysis compares a fresh scan of a DUST identifier against the reference captured when it was bound and records what it measured. The API returns measurements and evidence only — there is no summary number, band, threshold, or platform-authored result field anywhere in the surface, and alignmentOutcome reports solely whether the two scans could be compared at all (when they could not, the measurements are not comparable, which is not a statement about the identifier).
| Operation | Method & path |
|---|---|
| Run an Analysis | POST /api/v1/tamper/analyses — form-encoded: threadId, tagId, and exactly one of data or queryFingerprintId |
| Record an Observation | POST /api/v1/tamper/observations — { analysisId, result } |
| List a Thread’s Analyses | GET /api/v1/tamper/analyses?threadId=… (optionally tagId, limit) |
| Get one Analysis | GET /api/v1/tamper/analyses/{analysis_id} |
| Fetch a result bitmap | GET /api/v1/tamper/analyses/{analysis_id}/artifacts/{name} |
Running an Analysis takes a multipart/form-data or application/x-www-form-urlencoded body with threadId, tagId, and exactly one of:
data— the DUST scan itself, as a file or base64-encoded image. The service extracts it for you.queryFingerprintId— a fingerprint ID you already hold fromPOST /api/v1/tags/extract(above), if you extracted separately.
Sending both, or neither, is rejected. Either way an ordinary DUST capture is valid input — there is no separate capture path for tamper analysis. If the submitted scan cannot be read, the request fails and no Analysis is recorded.
A Tamper Observation is the only conclusion the platform stores, and it is authored by a person: result is one of consistent, expected, inconsistent, or unknown, has no default, and is required. expected records normal wear and tear for the identifier’s use case and substrate. Observations are immutable and attributed; a new one never replaces an earlier one, and reads return the whole series (observations, newest first) rather than a single current result. Do not derive a result from the metrics or collapse the series to one value in your own UI.
An Analysis carries metrics (a pass-through object of the algorithm’s coverage fractions and marker counts), optional markerPoints, and artifactNames. Marker coordinate sets are each in their own scan’s pixel space — compose them in one frame by applying metrics.transformation_matrix to the query points. Result bitmaps are protected content: fetch them through the artifact endpoint, which re-authorizes every request and returns non-cacheable bytes.
Labels
Section titled “Labels”A Label (wire name: composite tag, namespace /api/v1/composite-tags) is one physical label carrying one or more identifiers of any type; DUST is not required. Labels sit at a position on a Reel (collection.kind = "reel", identified by its UUID; its name is the printed reel number or any title and is never unique). Reels may be filed in a Label Collection (kind = "reel_collection"), a folder that is never shipped. A Reel’s expectedIdentifiers says how many Identifiers of each type a complete Label on it carries, as [{ "tagType", "count" }] (default one TEXT, one DUST and one QR; a count of 0 on input means the type is not expected). It is a hint for enrollment stations, not a constraint, and a Label’s complete flag means it has at least that many active Identifiers of each expected type.
| Operation | Method & path |
|---|---|
| List / create Label Collections | GET, POST /api/v1/composite-tags/collections; PATCH …/collections/{collection_id} |
| List Reels | GET /api/v1/composite-tags/reels?collectionId=…&unfiled=…&transferred=any|only|hide&q=… |
| Create a Reel | POST /api/v1/composite-tags/reels — { name, description?, collectionId?, expectedIdentifiers? } |
| Get / update a Reel | GET, PATCH /api/v1/composite-tags/reels/{reel_collection_id} (rename, expected composition, collectionId to move it; null unfiles it) |
| Create a Label | POST /api/v1/composite-tags/reels/{reel_collection_id}/labels (multipart) |
| Add / remove a member identifier | POST /api/v1/composite-tags/{composite_tag_id}/identifiers (multipart); DELETE …/identifiers/{tag_id} |
| List / get Labels | GET /api/v1/composite-tags?reelCollectionId=…&bound=any|only|unbound&transferred=…&q=…; GET …/{composite_tag_id} |
| Resolve a Label by a member value | POST /api/v1/composite-tags/resolve — { tagType, value, reelCollectionId? } (TEXT matches case-insensitively); the response carries detail (first match) and candidates[] (every match, in position order when a Reel is given) |
| Move or cut Labels | POST /api/v1/composite-tags/move; preflight with POST /api/v1/composite-tags/move/preview |
| Bind / unbind a Label | POST /api/v1/composite-tags/{composite_tag_id}/bind — { threadId, options?: { indexing: "default" } }; POST …/unbind |
| Bulk bind a Reel Range | POST /api/v1/composite-tags/reels/{reel_collection_id}/bulk-bind — { fromPosition, toPosition, threadIds, activate?, dryRun? } |
| Archive / restore a Label | POST …/{composite_tag_id}/archive, POST …/unarchive |
| Activate | POST …/{composite_tag_id}/activate; POST /api/v1/composite-tags/reels/{reel_collection_id}/activate (background) |
| Activate loose DUST identifiers | POST /api/v1/tags/activate — { tagIds[] } (up to 200); one outcome per identifier, forward-only |
Create a Reel and enroll Labels
Section titled “Create a Reel and enroll Labels”curl -fsS "$APID_URL/api/v1/composite-tags/reels" \ -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" \ --data '{ "name": "0030", "expectedIdentifiers": [{ "tagType": "TEXT", "count": 1 }, { "tagType": "DUST", "count": 1 }, { "tagType": "QR", "count": 1 }] }'The response is { reel }, a Reel summary with zero counts. Keep reel.collectionId; it identifies the Reel. Creating a Reel always creates: there is no reuse by name.
Each Label is one multipart request. Supply at most one DUST image as data; every other member comes through identifiers, a JSON array of { tagType, value } entries, or { tagType: "DUST", fingerprintId } for a further DUST already extracted with POST /api/v1/tags/extract. humanReadable and qrValue are shorthand for a TEXT and a QR member. position defaults to the next free position on the Reel.
curl -fsS "$APID_URL/api/v1/composite-tags/reels/$REEL_COLLECTION_ID/labels" \ -H "Authorization: Bearer $DUST_TOKEN" \ -H "Dust-Ctx-Org-Id: $DUST_ORG_ID" \ -H "Dust-Ctx-Team-Id: $DUST_TEAM_ID" \ -F "position=1" \ -F "data=@scan.jpeg" \ -F 'identifiers=[{"tagType":"TEXT","value":"AB00001"},{"tagType":"QR","value":"https://v.example/ab00001"}]' \ -F 'options={"indexing":"none"}'At least one identifier must result. A successful response has outcome: "created"; a retry with the same DUST marking at the same position reconciles as outcome: "already_enrolled". A DUST already on another Label, or already bound, returns 409 COMPOSITE_TAG_CONFLICT. Repeated TEXT or QR values are never rejected: the Reel reports them in warnings instead, because some reels legitimately repeat a value.
options.indexing selects the DUST indexing mode for the image in data: default (identifiable) unless you ask for none (Verify-only). Unattended enrollment stations typically enroll Verify-only and activate later; activation indexes each DUST and runs the platform’s duplicate check, so a Label whose DUST duplicates an indexed one is reported and skipped.
The equivalent typed-client flow is:
const created = await client.compositeTags.createReel({ name: "0030" });
const form = new FormData();form.set("position", "1");form.set("data", scanBlob);form.set("identifiers", JSON.stringify([{ tagType: "TEXT", value: "AB00001" }]));await client.compositeTags.createLabel(created.reel.collectionId, form);
const state = await client.compositeTags.getReel(created.reel.collectionId);await client.compositeTags.activateReel(created.reel.collectionId);Move and cut
Section titled “Move and cut”POST /api/v1/composite-tags/move takes a source, a target and an optional expectedCount.
Three source shapes:
{ compositeTagIds }— hand-picked Labels, moved in the order given.{ reelCollectionId, fromPosition, toPosition? }— a typed position range (a cut);toPositiondefaults to the Reel’s last position.{ fromCompositeTagId, toCompositeTagId }— a scan-bounded cut: the first and last Label of the span, in either order. The server reads their positions under lock; both must be active Labels on the same Reel (endpoints_on_different_reels,endpoint_archived,endpoint_not_on_reelindetail.reasonotherwise). Resolve each Label from a scanned value with/resolve(scoped to the Reel so a repeated printed code surfaces as severalcandidatesfor the caller to disambiguate) or, for an activated DUST, fromPOST /api/v1/tags/identify.
The target is { reelCollectionId } or { newReel: { name, description?, collectionId?, expectedIdentifiers? } } (a new Reel inherits the source Reel’s composition when none is given).
Every active Label inside a span moves; positions holding an archived or shipped Label, or no Label, are gaps that stay on the source Reel. Positions are kept when every one is free on the target, otherwise the whole batch is appended after the target’s last position in source order. The response lists moved[] and, for a range or scan-bounded source, cut: { sourceReel, fromPosition, toPosition, count, boundCount, boundPositions, gaps[] }.
expectedCount makes the count the contract: when given, the move is refused with 400 INVALID_REQUEST and detail.reason: "count_mismatch" (expected, actual, fromPosition, toPosition) unless exactly that many active Labels would move.
POST /api/v1/composite-tags/move/preview accepts the same source, an optional target and expectedCount, changes nothing, and returns span, count, boundCount, the first and last Labels of the batch, predictedOutcome (kept_positions / appended / null), countMatches and suggestedLast — the Label further along the Reel that would satisfy expectedCount when the span is short. It is a suggestion for the operator to scan, never applied by the server. Preview is member-tier; the move needs a Team or Organization administrator.
Bulk bind a Reel Range
Section titled “Bulk bind a Reel Range”POST /api/v1/composite-tags/reels/{reel_collection_id}/bulk-bind binds the Labels at positions fromPosition..toPosition (inclusive) to threadIds, in order: the k-th position to the k-th Thread. It is all-or-nothing and strict: the range must cover exactly threadIds.length positions (toPosition is required, not derived, so the caller states the range it verified on the spool), at most 500 pairs per call, and every position must hold an active, unbound Label. The server never skips a position, because a skip would silently shift every pairing after it.
Pass dryRun: true to preflight without binding. The response shape is the same either way:
outcome—"bound"after a real bind,"preflight"for a dry run.rows[]— one per pairing:index,position,compositeTagId(null for an empty position),labelName,textValue(the Label’sTEXTmember, the printed code),threadId,threadName,threadDescription.blockers[]andwarnings[]—{ kind, index, position, compositeTagId?, threadId?, tagType?, existing? }.activation—"queued","not_requested","already_active", or"no_dust".reel— the Reel summary with updated counts.
Blocker kinds: position_empty, label_archived, label_transferred, label_bound, label_no_identifiers, identifier_bound_elsewhere, identifier_in_other_team_label, thread_not_owned, thread_unavailable, thread_in_transfer, thread_not_editable, thread_repeated. Warning kinds: label_incomplete (a Label with fewer Identifiers than the Reel expects) and thread_has_label (the Thread already carries a Label; existing[] names them). Warnings never stop a bind.
A commit with any blocker fails with 409 COMPOSITE_TAG_CONFLICT; detail carries the same rows, blockers and warnings as a dry run, so a client only ever parses one shape. A range whose length differs from threadIds.length is a 400 INVALID_REQUEST.
Authority: Team membership for the Reel plus edit permission on each Thread. A Thread the caller cannot edit is a thread_not_editable blocker for that row rather than a rejection of the whole request, and every Thread must be owned by the Reel’s Team — a Thread merely shared with the Team is thread_not_owned.
With activate: true the bind commits first and one background job then activates exactly the bound Labels’ DUST markings; a Label whose activation fails stays bound and Verify-only. Poll the Reel’s counts.identifiableCount for progress.
# Preflightcurl -fsS "$APID_URL/api/v1/composite-tags/reels/$REEL_COLLECTION_ID/bulk-bind" \ -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" \ --data '{ "fromPosition": 1, "toPosition": 3, "threadIds": ["'$THREAD_1'", "'$THREAD_2'", "'$THREAD_3'"], "dryRun": true }'
# Commit, activating the bound Labels afterwardscurl -fsS "$APID_URL/api/v1/composite-tags/reels/$REEL_COLLECTION_ID/bulk-bind" \ -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" \ --data '{ "fromPosition": 1, "toPosition": 3, "threadIds": ["'$THREAD_1'", "'$THREAD_2'", "'$THREAD_3'"], "activate": true }'With the typed client:
const preview = await client.compositeTags.bulkBind(reelCollectionId, { fromPosition: 1, toPosition: threadIds.length, threadIds, dryRun: true,});if (preview.blockers.length === 0) { await client.compositeTags.bulkBind(reelCollectionId, { fromPosition: 1, toPosition: threadIds.length, threadIds, activate: true, });}The bind leaves exactly the events N single binds would: one bind event per member identifier, each carrying the Thread, the identifier and the Label as targets, all sharing one operation id.
Bind, unbind, and shipping
Section titled “Bind, unbind, and shipping”A Label binds as a whole: POST …/{composite_tag_id}/bind attaches the Label and every active member identifier to the Thread; options.indexing: "default" also activates it. The same happens when you call the ordinary POST /api/v1/tags/bind with any member identifier (see Binding a Label), which is what scanners do. Binding requires edit permission on the Thread and Team ownership of the Label, and the Thread must be owned by the same Team: a member identifier scanned onto a Thread that another Team shared with you is refused (409 COMPOSITE_TAG_CONFLICT, reason: "label_owned_by_other_team") rather than bound as a loose copy. POST …/unbind detaches the Label and all its members.
Team and Organization ownership come only from context headers, and the creator comes from the verified bearer token. Reading and activating Labels require Team membership. Creating Reels, enrolling, moving and archiving accept a signed-in Team or Organization administrator, or an Organization-scoped Service Account that is a member of the selected Team; a Service Account cannot borrow Organization-admin authority.
In Shipments, a Reel is a manifest item ({ kind: "reel", collectionId }) and ships whole, only while every Label on it is unbound. A bound Label ships with its Thread and never pulls its Reel into the Shipment. Shipped Reels and Labels remain readable on the sending side with transferredAt set; filter them with transferred=only or transferred=hide.
See Labels and Reels for the DICE workflow.
Related pages
Section titled “Related pages”- Errors and scan outcomes — the canonical outcome tables for identify and verify, and how to keep a scan receipt on failure
- Integrate with DUST Go — capturing DUST scans on mobile
- React Scanner — a copy-ready capture component
- Threads API guide — the records identifiers bind to
- API reference — full schemas, including capture metadata options