Scanning API
Open scan sessions, submit individual or bulk scans, and retry mobile ingestion safely.
Session ingestion
GET /api/v1/scan_contexts publishes the contexts available to a client before
it opens a session. It requires the scanning:read token ability and
scanning.access. The response is built from the live handler registry plus
configured record-only contexts; it is not a separately maintained list. The
catalogue is deliberately non-paginated: it returns one scan_contexts array,
without page, per_page, or pagination meta fields.
Each row contains key, label, target_type, target_requirements, a
machine-readable target_filter, capabilities, and handled. Target types
are stable morph slugs (rental, stock_check, warehouse_transfer, and
virtual_stock_intake). Existing clients may continue sending model FQCNs in
target_type; that compatibility will only be deprecated at a future major
version and with prior notice.
Those four slugs are the whole set of scannable target types. A context whose
target_requirements is empty — including a record-only context such as
flightcase_build — needs no target at all, but a target sent anyway must name
one of them and point at a row that exists: any other slug (user and
webhook are real morph aliases, but never scan targets) is a 422 on
target_type, and an id that resolves to nothing is a 422 on target_id.
| Context | Open-session target filter | Repeat scan semantics | Void | Handled |
|---|---|---|---|---|
check_out |
Rental; eligibility is checked per asset, not at open | duplicate |
Yes | Yes |
check_in |
Rental; eligibility is checked per asset, not at open | duplicate |
Yes | Yes |
global_checkin |
No target | duplicate |
Yes | Yes |
stocktake |
stock_check, API status 0 (open — covers both the draft and counting phases, but only a counting check can be opened), matching warehouse_id |
resolved |
Yes | Yes |
transfer |
warehouse_transfer, API status 1 (in transit), session warehouse equals destination_warehouse_id |
resolved |
Yes | Yes |
lookup |
No target | duplicate |
No | Yes |
prep |
No target | duplicate |
No | Yes |
sub_hire_receiving |
virtual_stock_intake, status confirmed, matching warehouse_id |
resolved |
Yes | Yes |
flightcase_build |
No target | duplicate |
No | No — scans are recorded without domain behaviour |
repeat_scan_semantics: duplicate means a second successful scan in the same
session is recorded as the duplicate outcome and does not run the handler.
resolved means repeats are handed to the context again for reconciliation.
The void capability is opt-in. Third-party context handlers remain compatible:
only handlers implementing VoidsScanEvents publish void: true.
Scan ingestion uses these write endpoints:
| Endpoint | Purpose |
|---|---|
POST /api/v1/scan_sessions |
Open a session |
POST /api/v1/scan_sessions/{id}/scans |
Submit one scan |
POST /api/v1/scan_sessions/{id}/scans/bulk |
Submit up to 100 ordered scans |
POST /api/v1/scan_sessions/{id}/scans/{scan_event}/void |
Append a compensating event and reverse a supported scan |
POST /api/v1/scan_sessions/{id}/close |
Close a session |
POST /api/v1/scan_sessions/{id}/abandon |
Abandon a session |
All six write endpoints support Idempotency-Key. They require the
scanning:write token ability plus the endpoint's existing scanning permission
and session/warehouse access checks. Their OpenAPI operations publish
x-signals-idempotency: auto; see API Conventions for
the three idempotency modes.
What changed in 0.9.0
These endpoint notes call out the additive contract changes scanner and SDK authors need to account for:
| Endpoint | What changed in 0.9.0 |
|---|---|
GET /api/v1/me |
Added warehouse_ids, warehouse_restricted, and default_warehouse_id; the complete identity, ability, quota, and warehouse payload is now the named MeData component. warehouse_restricted is true when warehouse_ids is a list and false when warehouse_ids is null for unrestricted access. |
GET /api/v1/scan_contexts |
Added runtime context discovery and made the operation explicitly non-paginated, removing ineffective page and per_page parameters from generated clients. |
POST /api/v1/scan_sessions |
Added automatic idempotency metadata so generated clients create a retry key before opening a session. |
POST /api/v1/scan_sessions/{id}/close |
Added automatic idempotency metadata so a lost response can be replayed without applying the transition twice. |
POST /api/v1/scan_sessions/{id}/abandon |
Added the same automatic retry protection for abandonment. |
POST /api/v1/scan_sessions/{id}/scans |
Added client_ref, occurred_at, rfid/imager, automatic idempotency, and an explicitly `object |
POST /api/v1/scan_sessions/{id}/scans/bulk |
Added ordered 1–100 item ingestion, named ScanBulkResult items, closed ScanBulkItemStatus values, and distinct 200/207 schemas. |
POST /api/v1/scan_sessions/{id}/scans/{scan_event}/void |
Added compensating voids, automatic idempotency, explicit scanning.scan plus scanning:write requirements, and ScanningErrorCode-typed 422 failures. |
GET /api/v1/workflow-runs/{id} |
Corrected workflow_run.outcome to an object when a redacted summary exists and null when no outcome exists. |
OpenAPI components
The 0.9.0 document promotes shared wire shapes to named components so SDK generators do not have to infer anonymous arrays independently on each operation:
| Component | Contract |
|---|---|
MeData |
Authenticated identity, roles, token abilities, grouped abilities, accessible warehouse IDs, explicit warehouse-restriction state, default warehouse, rate plan, live quota, and strict-idempotency setting. |
ScanBulkItemStatus |
Closed string enum: recorded, already_recorded, or failed. |
ScanBulkResult |
One bulk item result: input index, client_ref, typed status, optional outcome/entity/event, field errors, and code/message for coded domain refusals. |
ScanningErrorCode |
Stable scanning error vocabulary referenced by scanning 422 response code fields. |
ScanResultData.summary, bulk summary, and workflow_run.outcome are dynamic
JSON objects when present and null otherwise. They are never modelled as
lists, even when the runtime value is empty.
Reading sessions
GET /api/v1/scan_sessions and GET /api/v1/scan_sessions/{id} return a
scans_count for every session. The count includes every recorded outcome,
including unknown, blocked, refused, failed, and duplicate events.
Voids are separate rows with outcome voided; the original event exposes the
derived is_voided: true flag and the compensating row exposes
voids_scan_event_id. Originals are never updated or deleted.
Both endpoints accept include=user,warehouse. Each requested relationship is
returned as an {id, name} object; neither key is present when it was not
requested. Includes are allow-listed, and an unknown include returns 422
rather than silently broadening or changing the response.
Each row also contains the console's summary for its context. The shape is
context-specific because it comes from the same
ScanContextDispatcher::summaryFor() path as the operator console. For
example, lookup sessions return:
{
"summary": {
"scanned": 12,
"lookups": 9,
"unknown": 2,
"blocked": 1
}
}
For a lookup, scanned is every lookup performed, found or not: lookups
counts the ones that resolved, unknown the identifiers nothing matched, and
blocked the ones refused. The session page shows the same numbers as
Looked up, Found, Not found and Blocked.
Prep sessions return scanned, tests_due, clear, unknown and blocked.
Prep reports which equipment tests are due; it does not run them, so there is
no pass or fail count. scanned is every scan; a resolved scan counts under
tests_due when it found at least one test due and under clear otherwise.
The session page labels these Checked, Tests due, Nothing due,
Not found and Blocked.
Expected-set contexts expose their own reconciliation vocabulary, such as
expected, remaining, and unexpected. A registered context can legitimately
return an empty object, while a record-only context with no handler returns
null. Index summaries and counts are selected with the page query, so adding
include=user,warehouse does not introduce per-session queries.
Single scans
POST /api/v1/scan_sessions/{id}/scans accepts:
{
"raw_input": "ASSET-1001",
"input_method": "rfid",
"client_ref": "android-01J6QK3Y6",
"occurred_at": "2026-08-29T11:59:00Z"
}
input_method is one of manual, keyboard_wedge, camera_qr, rfid, or
imager. Every persisted event echoes it as scan_event.input_method (it is
also kept under metadata.input_method); a compensating void event carries
null. client_ref is optional, at most 128 characters, and should be a
stable client-generated identifier for the physical scan.
Retries must keep the same normalized identifier, input method and requested
quantity. Reusing a client_ref for different intent returns 422 with a
client_ref error; bulk ingestion reports that item as failed and continues
with unrelated items. This check applies even with a new HTTP idempotency key
and to direct scan-action callers. Case/whitespace changes accepted by the
selected input adapter do not change intent.
New scan events retain a version-1 metadata.scan_intent containing the
normalized input, input method and requested quantity. That requested quantity
is separate from a handler's metadata.quantity, which can describe the
quantity actually moved. Historical events with no frozen intent can be retried
only when their original input method is present, the normalized input matches,
and neither old nor new request carries quantity evidence. Missing method or
unprovable historical quantity intent is refused without rewriting the event.
The locked current session policy is checked before serving a saved result.
When the same session already contains that client_ref with matching intent, the endpoint returns
200 with already_recorded: true, the original event payload, the context
handler, and the session summary as it stands when the replay is served. A
fresh scan reports its summary the same way — read after the event is recorded,
so the scan you just sent is counted — and a replay therefore reads the same as
the original response apart from the flag, unless later scans have moved the
session on. A newly recorded scan has already_recorded: false.
A retry does not replace the original occurrence time, actor or event facts. Occurrence time is not part of movement identity; a changed client timestamp does not authorize another movement and the original timestamp is returned.
Bulk ingestion publishes one summary for the whole request rather than one
per item, so ScanBulkResult rows carry no summary of their own.
Capture times
An optional client occurred_at is accepted from
session.started_at - scanning.client_occurred_at_skew_seconds through
server receipt + scanning.client_occurred_at_skew_seconds. The default skew
is 300 seconds on each side. A time inside that window is persisted exactly;
there is no additional age limit while the session remains open.
A time outside the window is clamped to the nearest bound and the returned
event has occurred_at_clamped: true.
Items that omit occurred_at are stamped by the server. occurred_at is
stored at one-second precision, so within a bulk request each server-timed
item receives the receipt time plus its array index in whole seconds —
that is what keeps server-assigned times monotonic. A 100-item server-timed
batch therefore spans 99 seconds from receipt, and its later events carry
times after the request arrived. When the capture time matters, send
occurred_at per item: a client value inside the window is stored exactly, and
id order is the authoritative sequence either way.
Bulk scans
POST /api/v1/scan_sessions/{id}/scans/bulk accepts an items array in the
same shape as a single scan:
{
"items": [
{
"raw_input": "ASSET-1001",
"input_method": "imager",
"client_ref": "android-001",
"occurred_at": "2026-08-29T11:59:00Z"
},
{
"raw_input": "ASSET-1002",
"input_method": "rfid",
"client_ref": "android-002"
}
]
}
The envelope must contain 1–100 items. An oversized envelope returns 422 and
records nothing. Valid envelopes are processed in array order, including all
domain effects. Malformed items fail independently while their siblings still
run, and the session summary is calculated once after processing the batch.
Each result contains index, client_ref, status, outcome,
resolved_entity, and scan_event_id; failed items also contain errors.
The status field is the ScanBulkItemStatus enum, and each result is a
ScanBulkResult component in the OpenAPI document.
The response is 200 when every item succeeds and 207 Multi-Status when any
item fails. A repeated non-null client_ref within one request fails on its
later occurrence with duplicate_client_ref.
Only validation and authorisation failures are reported per item. A failure of
any other kind — a handler defect, a lost database connection — aborts the
request with a 5xx after the items before it have already committed, and no
per-item results are returned. A 5xx is never cached, so recover exactly as
for a wedged key: resubmit with a new Idempotency-Key and the same
client_ref values, and the committed items come back as already_recorded.
Safe retries
Idempotency is scoped by (token, key), so separate tokens may use the same
key independently. Claims and completed responses are retained for 24 hours.
Use one Idempotency-Key for one exact request body. A completed retry returns
the stored 200, 207, or 422 response verbatim with
Idempotency-Replayed: true. The same key with a different body returns 409;
an in-flight key returns 425.
If a server crash leaves a key wedged at 425, submit the same physical scans
with a new idempotency key while keeping each item's same client_ref.
The session-level client_ref dedupe then returns the original event with
already_recorded status instead of applying the scan twice. Server failures
are not cached; other non-validation 4xx claims are released.
Poll the wedged key with capped exponential backoff before minting a new one —
see Retrying a 425 in the API conventions for the
canonical cadence and why the bound is the client's to choose.
See API Conventions for the shared idempotency contract and the generated OpenAPI Reference for authoritative request and response schemas.
Identifier lookups
GET /api/v1/scannable_identifiers pages the registered identifiers — the
barcodes, asset numbers, and tags that a scan's raw_input resolves against.
It exists so a client can hold a local lookup cache and keep resolving while it
is offline, then reconcile when it reconnects. It requires the scanning:read
token ability and scanning.access.
Sync deltas with the updated_at predicates rather than re-walking the whole
set: ?q[updated_at_gteq]=2026-08-29T00:00:00Z returns everything touched at
or after a watermark, and q[updated_at_lteq] closes the other end of a
window. q[identifier_cont], q[kind_eq], q[identifiable_type_eq],
q[identifiable_id_eq], and q[is_active_eq] are the remaining filters;
anything else is skipped and reported in meta.ignored_filters.
identifiable_type is a stable morph slug, never a class name — asset is one
of the values; see the OpenAPI Reference for the closed
list. An identifier is deactivated rather than removed, so a delta sync sees
is_active: false instead of a disappearance.
The endpoint is read-only: there is no create, update, or delete route, and
scanning:write covers scan sessions and scans only. Registering, replacing,
or retiring an identifier is done in the application, not over the API.
Catalogue tombstones
Identifier deltas tell a client what can still be scanned; catalogue deltas
tell it what the identifier resolves to. GET /api/v1/catalogue_items supports
with_archived=true, which admits archived rows to the index and exposes the
archived_at field (an ISO 8601 string, or null for a live row) together with
the q[archived_at_gteq|lteq|null|not_null] predicates. Without the flag the
index is unchanged: archived rows stay hidden, and those predicates are
accepted but match nothing, because the default scope has already excluded
every archived row.
Archiving touches updated_at, so a delta sync that already tracks a watermark
picks tombstones up for free —
?q[updated_at_gteq]=<watermark>&with_archived=true returns the rows that were
archived since the watermark alongside the rows that changed, and a non-null
archived_at is the signal to drop the item from a local cache. GET /api/v1/catalogue_items/{id} still returns 404 for an archived item, so the
index is the only place a tombstone appears.
Voiding scans
POST /api/v1/scan_sessions/{id}/scans/{scan_event}/void accepts a required
reason string of at most 255 characters. It returns the compensating scan
event plus the refreshed context summary. Voiding is permitted only while the
session is open and is idempotent when callers reuse the same
Idempotency-Key and body. The caller needs the scanning.scan domain permission,
the scanning:write token ability, and the same session access as submitting a
scan: the session opener, or a user with scanning.manage_sessions.
The operation row-locks the original and refuses double voids and attempts to void a compensating event. A void removes the original from duplicate detection, so duplicate-semantic contexts can scan the identifier again. Repeat-scan contexts reverse one contribution at a time: stocktake decrements one count, transfer creates opposite ledger postings and restores one receipt, and sub-hire receiving removes one received asset and reopens the intake only when no active receipt remains. Transfer ledger history is append-only: the original postings remain and the compensation adds opposite postings.
Error codes
Scanning-relevant endpoints use these stable machine-readable strings in a
top-level code, a validation error, a guard denial, or a scan outcome. Clients
should branch on the code and render the accompanying message for people.
| Code | Meaning |
|---|---|
unauthenticated |
No valid bearer token was supplied |
forbidden |
The token ability, scanning permission, or warehouse policy refused access |
not_found |
The addressed session or target does not exist in the caller's scope |
virtual_stock_not_received |
Virtual/sub-hired kit has no current receipt evidence; receive the intake before allocation, substitution or check-out. Receipt undo keeps planning cover and the standing PO. |
invalid_state |
The target or session cannot perform the requested transition in its current state |
duplicate |
The same successful identifier was scanned again in a duplicate-semantic context |
invalid_reference |
A supplied identifier or related record reference is invalid |
missing_required_value |
A required context value is absent |
constraint_violation |
A target, warehouse, or domain invariant was violated |
duplicate_client_ref |
A later bulk item repeats a non-null client_ref from the same request |
void_unsupported |
The context does not opt in to voiding |
void_unsupported_outcome |
The original outcome has no domain effect to compensate |
void_state_changed |
Downstream state changed and safe compensation is no longer possible |
session_closed |
Voids are accepted only while the session is open |
already_voided |
The original already has a compensating event |
void_of_void |
A compensating event cannot itself be voided |
No rate-limit headers are returned by design to an authenticated caller while
unit throughput metering is enabled, which it is by default — any token, not
only high-throughput ones; the unit headers, the metering-disabled fallback,
and the unauthenticated exception are described under Rate limiting in
API Conventions. Discover the token's configured
rate and live unit quota from GET /api/v1/me (rate_limit_per_minute,
units_remaining_this_minute, and daily_remaining).