SIGNALS Documentation
API Reference

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).