AutoID print loop
Norruva creates the identity; the print layer (e.g. TSC) prints and encodes it; the outcome comes back per serial. Four legs, webhooks both ways, print execution stays the printer partner's job, Norruva stays the system of record for what was (and wasn't) printed.
The loop
POST /api/v2/carriers/batch { "productId": "…", "count": 1000 }
→ 201 { allocated, serials[] } → webhook carrier.generated
("codes ready", eventData carries exportPath; no polling)
GET /api/v2/carriers/export?productId=…
→ the print CSV: gtin, serial, digital_link, sgtin96_epc
(digital_link IS the QR payload; sgtin96_epc feeds the RFID encoder)
…the print layer prints…
POST /api/v2/carriers/print-status
{ "results": [ { "serial": "…", "status": "printed", "deviceId": "PEX-2000-01" },
{ "serial": "…", "status": "failed", "reason": "ribbon jam" } ] }
→ 200 { updated[], unknown[], printed[], failed[], reportedAt, events[] }
→ webhooks print.confirmed / print.failedThe endpoints
| Call | Purpose | Auth |
|---|---|---|
POST /api/v2/carriers/batch | Allocate up to 10k numeric, SGTIN-96-safe serials for a product. Idempotent via Idempotency-Key; fires carrier.generated so subscribers pull instead of poll. | KEY production:create |
GET /api/v2/carriers/export | The print contract (CSV or format=json). Optional printState=printed|failed|pending narrows to the reported outcome, printState=failed is exactly the re-print file; an unknown value refuses with 422. | KEY production:view (identifiers:read satisfies it) |
POST /api/v2/carriers/print-status | Per-serial outcomes from the print layer, ≤1000 per call: printed or failed + optional reason / deviceId. | KEY epcis:capture or production:create |
Semantics that matter
- Idempotent by construction. Outcomes land on the serialization record (
metadata.print: status, reason, deviceId, reportedAt) and are replaced wholesale, a later re-print legitimately overwrites an earlier failure (last write wins, also within one request). - A retry replays; it does not re-report. Because print controllers rarely send an
Idempotency-Key, the endpoint derives one from the batch itself. Re-posting an identical batch returns the first response and fires no further webhooks, so a timed-out report is safe to retry without duplicatingprint.confirmed/print.failedfor subscribers. Theevents[]counts in a replayed response describe the original call. Change any outcome in the batch and it hashes differently, so genuinely new results always execute. - Unknown serials are reported, never dropped. Anything not found for your tenant comes back in
unknown[]with a200, the known outcomes still commit. - Aggregate events, not per-serial spam. One
print.confirmedand/or oneprint.faileddelivery per request, each carrying the serial list (+ per-serialfailures[]reasons on the failed event), a 10k-label run is 2 deliveries, not 10k. - Webhook dispatch failures never fail the call. The outcome is already durably recorded;
events[]in the response tells you what was actually notified.
Report outcomes, example
curl -X POST "$BASE/api/v2/carriers/print-status" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{ "results": [
{ "serial": "100001", "status": "printed", "deviceId": "PEX-2000-01" },
{ "serial": "100002", "status": "failed", "reason": "ribbon jam" } ] }'{ "updated": ["100001", "100002"], "unknown": [],
"printed": ["100001"], "failed": ["100002"],
"reportedAt": "2026-07-22T17:20:00.000Z",
"events": [ { "type": "print.confirmed", "webhooksNotified": 1 },
{ "type": "print.failed", "webhooksNotified": 1 } ] }The re-print file
After outcomes are in, GET /carriers/export?productId=…&printState=failed returns exactly the serials that need another pass, same CSV columns, so the same file feeds the same print pipeline. printState=pending answers "what has the print layer not reported yet?" for reconciliation.
Tier 2, orchestrated print jobs (AIDC)
The CSV loop above trusts the print layer to manage its own work. The print-job tier (/api/v2/aidc/*, F15) makes each label an orchestrated, evidence-carrying unit instead: Norruva creates a job for one allocated serial, an enrolled device claims it under a lease and receives it inside a signed envelope, reports each state transition with its evidence artifact, and the terminal outcome lands on the same metadata.print spine the CSV loop uses, so the export, the re-print file and the webhooks stay one truth whichever tier printed the label.
POST /api/v2/aidc/jobs { "gtin": "…", "serial": "…" } ← serial must be allocated
→ 201 job resource (fails 422 unless the passport is readyToPrint)
POST /api/v2/aidc/jobs/claim ← THE device pulls (no inbound port into the plant)
→ signed envelope + attempt_id + lease
POST /api/v2/aidc/jobs/{ref}/heartbeat ← long run? extend the lease
POST /api/v2/aidc/jobs/{ref}/result { "status": "RENDERED", "evidence": {…}, "attempt_id": "…" }
…repeat per state… SPOOL_SUBMITTED → PRINTED_ATTESTED → SCAN_VERIFIED → SEALED
→ SEALED: metadata.print=printed + EPCIS commissioning + print.confirmed
→ DEAD_LETTER (retries exhausted): metadata.print=failed → the re-print fileVocabulary note for printer-vendor integrations: on this platform a print job (print_job, identified by job_ref) is the shared lifecycle object that survives retries, and a print attempt (print_attempt) is one physical try, reissued on each retry. A printer vendor's own "print job" (one command stream sent to one printer) maps to our print_attempt; a vendor's "DPP job" maps to our print_job. There is deliberately no dpp_job object or alias: one object, one name, and the two-noun mapping keeps the one-to-many relation between a lifecycle and its attempts visible.
The job lifecycle, states are evidence-gated
| State | Meaning | Required evidence | Who may assert it |
|---|---|---|---|
QUEUED | Created from an allocated serial; waiting for a claim. | - | issuer |
CLAIMED | A device holds it under a lease (fresh attempt_id per claim). | - | device (via claim) |
RENDERED | Label content produced. | render_manifest | device / operator |
SPOOL_SUBMITTED | A spooler accepted the job, machine-observable, nothing more. | spool_receipt | device / operator |
PRINTED_ATTESTED | Paper came out. Actor-attested only, a spool submission can never imply it (the honesty constraint). | - | device or human only, never system |
SCAN_VERIFIED | The printed symbol decoded back to the expected payload. | scan_result | device / operator |
SEALED | Terminal success, evidence bundle sealed. Propagates printed to the spine + EPCIS commissioning. | evidence_bundle | device / operator |
FAILED | Retryable up to max_attempts, re-enters the claim pool. | - | any |
DEAD_LETTER | Terminal failure, retry budget exhausted (reaper). Propagates failed → the re-print file. | - | system (reaper) |
CANCELLED | Terminal, before physical work, no print outcome to report. | - | issuer |
The four client guarantees
- Lease. A crashed device's job is reclaimable the moment its lease expires, no human, no reaper on the happy path. Heartbeat to keep a long run.
- Attempt id. Every claim mints and PERSISTS a fresh
attempt_id; a result from a superseded attempt is a409 STALE_ATTEMPT, an expired envelope a410. - Idempotency. Send
Idempotency-Keyon results, the key is claimed inside the transition transaction, so a retry after a timeout replays instead of double-applying. - Atomic outcomes. A terminal transition and the spine write (
metadata.print) commit in ONE transaction, there is no window where the job says printed and the export says pending.
Two creation gates, both typed 422s: SERIAL_NOT_ALLOCATED (allocate via carriers/batch first, a job can never mint a serial) and PASSPORT_NOT_READY (the F6 readyToPrint gate: ≥ 1 anchored validation ∧ published; anchor via POST /passports/{id}/validations). Full endpoint reference: Print jobs & devices; operator walkthrough: Run a print device.
POST /epcis/capture with the same key), and a dedicated verify-label endpoint is not shipped, see Deviations. Subscribe to the trio on one endpoint via Webhooks & events.